Compare commits

...
Author SHA1 Message Date
julioandClaude Opus 5.5 43770c6d67 feat(celular): sem a placa a carinha dorme e mostra placa ✕ celular
Sem servidor o app não finge mais que está tudo normal:
- o app guarda a última aparência e os ajustes vindos da placa (ponte
  CarinhaApp); a cópia do APK abre com eles;
- a carinha dorme e não acorda com nada; no canto direito aparece a
  placa ✕ celular animada com "sem conexão com a placa";
- o app tenta a placa a cada 10 s e volta sozinho; se a placa cai com
  a página aberta, em 15 s ela dorme e mostra o aviso;
- endereço padrão passa a ser a Orange Pi (192.168.1.35:8100).

Também no app (0.9.0):
- modo quiosque como dono do aparelho (Quiosque/Admin, menu "Modo
  quiosque"); a Samsung recusa com bootloader desbloqueado, vale no
  LineageOS;
- carinha como tela inicial (HOME);
- android/enxugar-s10.ps1 desativa 57 apps sem uso no S10 (-Reativar
  desfaz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:17:28 -03:00
julioandClaude Opus 5.5 f8d2ebf705 fix(placa): Wi-Fi só vale se alcança o roteador da casa
Associar numa rede que não é a da casa fazia o serviço dar o Wi-Fi como
conectado, mover o cabo para .34 e responder pelo wlan0: a placa sumia da
rede. Agora:
- a conexão só conta quando o gateway responde pelo wlan0;
- falha com mensagem "conectei em X, mas não chego ao roteador";
- rotas do wlan0 com métrica 600 (o cabo continua preferido);
- ignore_routes_with_linkdown: cabo desligado não segura rota.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:17:28 -03:00
julioandClaude Opus 5.5 d7058aaa7a chore(git): arquivos da placa sempre com fim de linha LF
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:44:03 -03:00
julioandClaude Opus 5.5 50b6f97b50 feat(placa): Wi-Fi com modo de configuração "Carinha-Config"
Serviço carinha-wifi (sistema/wifi, só biblioteca padrão + hostapd e
dnsmasq como filhos). Com a rede salva, conecta como cliente e assume o
endereço fixo 192.168.1.35; sem rede salva, ou se a rede sumir por 3 min,
vira o ponto de acesso "Carinha-Config" com página de configuração que
abre sozinha no celular (responde aos testes de internet do Android,
iPhone e Windows e anuncia o portal por DHCP).

- Lista as redes encontradas com o sinal, aceita rede escondida e opção
  de endereço fixo ou DHCP; senha guardada só para root (PSK derivada).
- Senha errada: a rede anterior continua salva e o ponto de acesso volta
  com o aviso. Sem ninguém conectado, tenta a rede salva a cada 5 min
  (faltou luz e a placa ligou antes do roteador).
- Quando o Wi-Fi assume o .35, o cabo passa para 192.168.1.34 (reserva);
  arp_ignore/arp_announce evitam conflito com os dois ligados.
- Economia de energia do Wi-Fi desligada (atrasos e quedas no unisoc).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:43:54 -03:00
julioandClaude Opus 5.5 05e82dfd75 feat(criacao): estúdio para criar expressões, atividades e figurinhas com IA
Menu "Criar com IA" (Ao vivo › Gestos e Reações) com grade igual à dos
favoritos. Para cada pedido escolhe-se o tipo (expressão, atividade ou
figurinha) e a IA: Gemini, IA local (qualquer servidor compatível com
OpenAI na rede, como o ROG Ally com LM Studio/llama-server) ou o Qwen do S10.

- A IA não escreve código: responde uma receita de formato fixo (schema
  JSON) montada só com peças que o rosto já anima. O servidor valida,
  limita números e descarta peças desconhecidas antes de chegar ao celular.
- Reaproveita o que existe: o prompt leva exemplos reais dos gestos e
  atividades atuais, e "Basear em" manda a receita de um gesto, atividade
  ou criação para a IA fazer uma variação.
- Expressões viram sequências como as de sequencias(), com mãos e espiral.
- Atividades usam uma cena genérica com o bonequinho (poses, pernas, pulo,
  balanço) e os objetos das cenas feitas à mão (bola, livro, xícara,
  notas, estrelas, coração, Zzz, chama, bolha, halter, microfone,
  controle, pincel, flor), com movimentos pular/girar/subir/orbitar.
- Figurinhas reaproveitam o desenho e a biblioteca de reações.
- A estrela põe expressões e atividades criadas nos favoritos (e nas
  teclas 1 a 9); a IA local é configurada na própria tela, com teste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:22:52 -03:00
julioandClaude Opus 5.5 ff06f02705 fix(rosto): parado mais humano; tonto com espirais e bocejo com a mão
Parado ele vivia escondendo o rosto: a IA local (Qwen 0.6B) reagia a cada
som ou "sozinho" a cada 20-60 s e repetia 'esconder' apesar do "varie".

- IA local: cada evento só oferece gestos que combinam (barulho: susto ou
  olhar; movimento: olhar e acenar), os dois últimos ficam fora do enum,
  no máximo a cada 45 s, não interrompe atividade e, com o modo vida
  ligado, não reage a "sozinho".
- Sem ninguém olhando (sem movimento/PIR há 3 min) nada de gesto "para
  alguém" (acenar, tímido, esconder...): ele vive a vida dele e começa
  atividades com mais frequência.
- Gestos soltos no celular: quase sempre sutis (olhar de lado, piscar
  devagar), gesto grande raro e nunca repetido em seguida.
- Olhar em repouso com fixações e sacadas em vez do vaivém contínuo.
- Tonto: espirais girando no lugar dos olhos (todos os rostos, inclusive
  Matriz) e boca de "ó". Bocejo: mão na frente da boca com tapinhas e
  Zzz subindo. As mãos agora aparecem também quando o gesto vem sozinho.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:38:32 -03:00
julioandClaude Opus 5.5 e73897d9c1 feat(vida): 20 atividades e necessidades no estilo The Sims
Quatorze cenas novas para o bonequinho: cochilar na caminha (cobertor
respirando e Zzz), café (vapor e gole), cozinhar (vira a panqueca no
fogão), regar a plantinha (cresce e dá flor), violão, dançar (globo
espelhado), pular corda, meditar flutuando, telescópio (céu estrelado e
estrela cadente), pipa com rabiola, bolhas de sabão, skate (com ollie),
computador (código aparecendo) e pescar (o peixe pula preso na linha).

Necessidades puxam comportamento, como no The Sims:
- energia < 40%: às vezes cochila; < 20%: dorme de verdade com sono
  respeitado (som comum, movimento e PIR não acordam) até 95%; acordar
  de propósito (toque, painel) encerra o sono pesado;
- alegria abaixo de 60%: o rosto vai ficando emburrado (zanga 0 a 1:
  testa franzida, boca para baixo, olhos bravos); < 30%: mal-humorado;
- diversão < 45%: faz uma atividade de 2 a 5 min escolhida pelo pique
  (cansado prefere calmas, café dá energia); pode ser interrompido;
- durante a atividade a diversão sobe e a energia cai conforme o tipo.

Com o modo vida ligado o celular não adormece mais por 20 s de silêncio:
quem decide o sono é a energia. A cor escolhida nunca muda com a zanga.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:28:09 -03:00
julioandClaude Opus 5.5 d7a17b5683 feat(rosto): atividades (momentos dele) e seis gestos novos
Atividades: a tela se divide, o rosto vai para a esquerda e encolhe, e
à direita um bonequinho do Carinha (silhueta na cor dos olhos) faz algo:
ler (vira a página), cantar (microfone e notas), jogar bola
(embaixadinha), videogame (TV com joguinho), pintar (cavalete que ganha
pinceladas) e malhar (halteres e suor). Os olhos grandes acompanham:
leem as linhas, seguem a bola, olham a TV. Camada própria atrás dos
olhos, vale para todos os rostos; em pé (retrato) a cena vai para baixo.

- Comando {tipo: "atividade", valor: nome|parar}; toque na tela, dormir
  e E-Stop encerram; o celular informa a atividade na telemetria.
- Modo vida escolhe pelo humor (feliz canta e joga bola, tranquilo lê
  e pinta, entediado joga videogame), nunca de madrugada, e não
  interrompe uma atividade em andamento.
- Gestos novos: rir, comemorar, tímido, confuso, bocejar e tonto; o
  humor e o modo vida também os usam.
- Painel: favoritos vão até 20 e o bravo volta junto com os novos;
  botões de atividade no Ao vivo, no Ctrl+K e tópico no guia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:12:14 -03:00
julioandClaude Opus 5.5 48fd51e377 feat(web): guia do painel com botão ⓘ em cada seção
Um ⓘ ao lado do título de cada seção (Ao vivo, Vida, Eventos, Ajustes,
Reações, Conversa, Movimento) abre a explicação daquela parte: o que faz
cada opção, onde fica e uma dica. No cartão Vida também há ⓘ para
humores, cuidados e ritmo. O ⓘ do topo explica a tela atual.

A página Guia (#/guia) junta todos os 21 tópicos em ordem de tutorial,
com índice e busca; cada tópico também aparece no Ctrl+K e há atalho em
Ajustes › Sistema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:12:38 -03:00
julioandClaude Opus 5.5 9e09df40e2 feat(vida): ritmo também define a sensibilidade dos sensores
O servidor manda "ritmo calmo|normal|agitado" ao celular junto com o
humor, e o rosto ajusta limiares e quantos estímulos precisa:

- calmo: limiares 50% mais altos, 3 sustos seguidos para reagir, o dobro
  de sons e 4 movimentos para acordar, 3 disparos do PIR em 2 min;
- normal: reage a barulho alto e movimento forte, 2 movimentos ou os
  sons configurados para acordar;
- agitado: limiares 40% mais baixos, reage a qualquer som ou movimento
  (com reações variadas) e acorda no primeiro estímulo.

A madrugada silenciosa continua valendo por cima. Com o modo vida
desligado o celular volta ao "normal" (só os Ajustes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:39:14 -03:00
julioandClaude Opus 5.5 dd40c730be feat(sono): madrugada silenciosa e N sons para acordar
Antes um único som acima do limiar, um quadro de movimento ou um pulso
do PIR acordavam o rosto; deitado na cama, ele acordava toda hora.

- Som: precisa de `somAcordar` barulhos (padrão 3) em 1 minuto; o mesmo
  barulho comprido conta uma vez.
- Madrugada silenciosa (padrão 23h às 7h, configurável): movimento,
  rastreamento, PIR e modo presença não acordam; só barulho ALTO
  repetido, toque ou conversa.
- Vida: de madrugada não acorda sozinho e volta a dormir mesmo com gente
  se mexendo, a não ser que alguém tenha interagido nos últimos 10 min.
- Painel: grupo "Sono e madrugada" nos Ajustes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:48:11 -03:00
julioandClaude Opus 5.5 5fcd038861 feat(rosto): microexpressões na boca e nas sobrancelhas acordado
De vez em quando, fora dos gestos, um canto da boca sobe, uma sobrancelha
arqueia, a testa franze pensando, a boca entreabre num suspiro, e tudo
volta suave ao neutro. O humor do modo vida escolhe quais (carente fica
preocupado, feliz dá sorrisinhos, cansado suspira). As sobrancelhas
descem um pouco ao piscar. Vale para o SVG e para a Matriz de LEDs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:31:54 -03:00
julioandClaude Opus 5.5 b456f80716 feat(vida): modo autônomo, o Carinha como bichinho virtual
Novo módulo vida.py: energia, alegria, companhia e diversão caem com o
tempo e sobem com presença (PIR), movimento, conversa e carinho. Delas sai
o humor e, do humor, os atos: gestos, cochilo quando cansado, dormir à
noite sem ninguém por perto, acenar pedindo atenção quando carente com
gente perto, tocar reações da biblioteca e inventar figurinhas novas com
o Gemini (no máximo 3 por dia, 90 min entre elas).

- O celular recebe "humor x" e escolhe os gestos soltos por ele.
- Toque na tela do celular conta como carinho/cutucada (/vida/cuidar).
- Respeita E-Stop e o modo presença (ninguém em casa = quieto).
- Painel: cartão Vida no Ao vivo com humor, barras, cuidados, ritmo e
  diário; liga por padrão.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:21:23 -03:00
julioandClaude Opus 5.5 8d410dbf24 fix(ao-vivo): rosto do painel dorme e acorda junto com o celular
O celular adormece sozinho (silêncio sem movimento) e acorda com som,
movimento ou toque; isso vem na telemetria, não no SSE de ordens, e a
prévia do painel continuava acordada. Ao vivo e mini-rosto agora seguem
o `dormindo` da telemetria; as prévias de edição (Rosto, Reações) não.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:08:25 -03:00
julioandClaude Opus 5.5 fd6a14054d fix(pir): acompanha o nível do pino em vez de só a borda de subida
O HC-SR505 segura a saída em alto ~8 s e rearma a cada movimento; com
alguém na frente o pino ficava alto direto e passar a mão não gerava
borda nova, então o painel parecia não detectar nada. Agora o gpiomon
escuta as duas bordas, o nível inicial vem do gpioget e o status/SSE
levam `ativo`. A bolinha do Ao vivo fica acesa enquanto o pino está
alto, e o modo presença não manda dormir com o sensor ainda segurando.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:04:41 -03:00
julioandClaude Opus 5.5 784042335c fix(deploy): tira o BOM do script enviado à placa
O PowerShell 5.1 manda BOM no stdin do ssh e o bash lia "<BOM>set -e"
como comando inexistente, rodando a instalação sem parar no primeiro erro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:57:53 -03:00
julioandClaude Opus 5.5 cf54a93b53 feat(ao-vivo): pontinho do PIR sobre a imagem do palco
Verde pulsando por 8 s depois de cada disparo, cinza sem presença;
some quando o sensor não está disponível.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:56:48 -03:00
52 changed files with 6817 additions and 193 deletions
+4
View File
@@ -0,0 +1,4 @@
# Arquivos que rodam na placa (Linux): sempre com fim de linha LF
*.sh text eol=lf
*.service text eol=lf
sistema/** text eol=lf
+19 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto"
android:versionCode="11"
android:versionName="0.8.3">
android:versionCode="12"
android:versionName="0.9.0">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
@@ -36,6 +36,23 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- no modo quiosque a carinha é a tela inicial do aparelho -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- dono do aparelho (modo quiosque): adb shell dpm set-device-owner br.carinha.rosto/.Admin -->
<receiver
android:name=".Admin"
android:exported="true"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data android:name="android.app.device_admin" android:resource="@xml/admin" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>
</application>
</manifest>
+469 -52
View File
@@ -5,7 +5,13 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}
svg{display:block;width:100%;height:100%}
svg{display:block;width:100%;height:100%;position:relative;z-index:2}
/* atividade (momentos dele): a tela se divide. Deitado, o rosto vai para a esquerda e o bonequinho
aparece à direita; em pé, o rosto sobe e ele aparece embaixo. */
#tela,#rostoMotor{transition:transform .8s cubic-bezier(.2,.8,.2,1);transform-origin:50% 50%}
body.atividade #tela,body.atividade #rostoMotor{transform:translateX(-23%) scale(.74)}
@media (orientation:portrait){body.atividade #tela,body.atividade #rostoMotor{transform:translateY(-20%) scale(.8)}}
body.atividade #tela>rect{opacity:0} /* o fundo preto do SVG não pode tapar o bonequinho */
.corpo{fill-rule:evenodd}
.traco{fill:none;stroke-linecap:round}
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
@@ -14,6 +20,52 @@
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
z-index:5;pointer-events:none;text-align:center;opacity:.82;font-family:system-ui,sans-serif;
animation:semServidorEntra 1.2s ease-out}
#semServidor svg{display:block;margin:0 auto;overflow:visible;filter:drop-shadow(0 0 3px currentColor)}
#semServidor svg.horizontal{display:none}
#semServidor svg.vertical{height:58vh;width:auto;max-width:100%}
#semServidor svg.vertical .olhinhos{animation-name:semServidorOlharCima}
@media (orientation:portrait){
body.semServidor #tela,body.semServidor #rostoMotor{transform:translateY(-20%) scale(.8)}
#semServidor{right:auto;top:auto;left:50%;bottom:3.5vh;transform:translateX(-50%);width:min(80vw,400px)}
#semServidor svg.horizontal{display:block;width:100%;height:auto}
#semServidor svg.vertical{display:none}
}
#semServidor .titulo{text-wrap:balance;font-weight:600;font-size:clamp(12px,3.2vh,20px);margin-top:.4em;letter-spacing:.02em}
#semServidor .sub{font-size:clamp(10px,2.4vh,15px);opacity:.6;margin-top:.25em;font-variant-numeric:tabular-nums}
#semServidor .placa rect,#semServidor .celular>rect{fill:none;stroke:currentColor;stroke-width:3}
#semServidor .chip{fill:currentColor;fill-opacity:.25}
#semServidor .pino{fill:currentColor;opacity:.7}
#semServidor .led{fill:#ff5a5a;animation:semServidorLed 1.6s steps(1) infinite}
#semServidor .cabo{stroke:currentColor;stroke-width:3;stroke-dasharray:5 6;opacity:.5;fill:none}
#semServidor .arco{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;opacity:0;
animation:semServidorArco 2.4s ease-out infinite}
#semServidor .a2{animation-delay:.3s}
#semServidor .a3{animation-delay:.6s}
#semServidor .xis path{stroke:#ff5a5a;stroke-width:5;stroke-linecap:round;fill:none}
#semServidor .xis{transform-box:fill-box;transform-origin:center;animation:semServidorXis 2.4s ease-in-out infinite}
#semServidor .olhinhos{fill:currentColor;transform-box:fill-box;transform-origin:center;
animation:semServidorOlhar 5s ease-in-out infinite}
#semServidor .boquinha{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round}
#semServidor .celular{transform-box:fill-box;transform-origin:50% 100%;animation:semServidorProcura 4.8s ease-in-out infinite}
#semServidor.cutucado .celular{animation:semServidorChacoalha .5s ease-in-out}
@keyframes semServidorEntra{from{opacity:0}to{opacity:.82}}
/* em pé no canto direito: o celularzinho olha para cima, para a placa */
@keyframes semServidorOlharCima{0%,30%{transform:translateY(-3px) scaleY(1)}34%{transform:translateY(-3px) scaleY(.1)}38%,60%{transform:translateY(-3px) scaleY(1)}
70%,90%{transform:translateY(1px) scaleY(1)}100%{transform:translateY(-3px)}}
@keyframes semServidorArco{0%{opacity:0}25%{opacity:.9}70%,100%{opacity:0}}
@keyframes semServidorXis{0%,55%,100%{transform:scale(1);opacity:.75}65%{transform:scale(1.25);opacity:1}80%{transform:scale(1);opacity:.85}}
@keyframes semServidorLed{0%{opacity:1}50%{opacity:.15}}
/* o celularzinho olha para a placa, olha de volta e pisca */
@keyframes semServidorOlhar{0%,30%{transform:translateX(-3px) scaleY(1)}34%{transform:translateX(-3px) scaleY(.1)}38%,60%{transform:translateX(-3px) scaleY(1)}
70%,90%{transform:translateX(1px) scaleY(1)}100%{transform:translateX(-3px)}}
@keyframes semServidorProcura{0%,100%{transform:rotate(0)}20%{transform:rotate(-5deg)}40%{transform:rotate(0)}}
@keyframes semServidorChacoalha{0%,100%{transform:rotate(0)}25%{transform:rotate(-9deg)}50%{transform:rotate(7deg)}75%{transform:rotate(-4deg)}}
@media(prefers-reduced-motion:reduce){#semServidor *,#semServidor{animation:none!important}#semServidor .arco{opacity:.6}}
</style>
<body>
<svg id="tela" viewBox="0 0 480 320" preserveAspectRatio="xMidYMid meet">
@@ -100,7 +152,7 @@ function cuidarDaTela() {
const agora = performance.now();
if (!r.dormindo) SONO.desde = 0;
else if (!SONO.desde) SONO.desde = agora;
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
const apagar = SONO.apagar && r.dormindo && !window.carinhaSemServidor && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
if (apagar !== SONO.apagada) {
SONO.apagada = apagar;
window.carinhaTelaApagada = apagar;
@@ -203,8 +255,25 @@ function caminhoBoca(tipo) {
return "M " + (cx - meia) + "," + (cy - l * 0.10) +
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
}
return "M " + (cx - l * 0.30) + "," + cy + // repouso: leve
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0";
// repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
const f = FEICAO.atual, w = l * 0.30, mx = cx + f.dx * l * 0.10;
const yE = cy - Math.max(0, f.lado) * l * 0.12, yD = cy - Math.max(0, -f.lado) * l * 0.12;
const curva = l * (0.18 + f.curva * 0.35 - ZANGA * 0.3);
let d = "M " + (mx - w) + "," + yE + " Q " + mx + "," + (cy + curva - f.abre * l * 0.12) + " " + (mx + w) + "," + yD;
if (f.abre > 0.04) d += " Q " + mx + "," + (cy + curva + f.abre * l * 0.30) + " " + (mx - w) + "," + yE;
return d;
}
/* Sobrancelha i (0 = esquerda): humor do gesto + microexpressão + desce um pouco ao piscar. */
function caminhoSobrancelha(i, esc) {
const c = i === 0 ? E1 : E2, lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, f = FEICAO.atual;
const ab = i === 0 ? r.ab : (r.ab2 === null ? r.ab : r.ab2);
const piscando = 1 - Math.min(1, Math.max(0, ab));
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC - (f.sob[i] - piscando * 3) * ESC;
const base = r.forma === 'bravo' ? 18 : r.forma === 'triste' ? -14 : ZANGA * 14; // zanga franze a testa
const inc = (base + f.inc[i]) * lado * ESC;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
return 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele);
}
// ------------------------------------------------------------------ desenho
@@ -256,6 +325,13 @@ function olho(indice, cx, cy, rx, ry, ab, forma, cor, lado, lx, ly) {
if(p.desenho!==desenho){if(p.figurinha)p.figurinha.remove();p.figurinha=window.carinhaConstruirSVG(desenho);p.desenho=desenho;if(p.figurinha)document.getElementById('o'+(indice+1)).append(p.figurinha);}
if(p.figurinha){p.figurinha.style.display='';p.figurinha.setAttribute('x',cx-rx*.65);p.figurinha.setAttribute('y',cy-ry*.65);p.figurinha.setAttribute('width',rx*1.3);p.figurinha.setAttribute('height',ry*1.3);}
}
if (espiralAtiva()) {
p.traco.setAttribute("d", caminhoEspiral(cx, cy, Math.min(rx, ry) * 0.95, lado));
p.traco.setAttribute("stroke-width", rx * 0.17);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
return;
}
if (icone) {
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
@@ -345,7 +421,23 @@ function sequencias() {
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
return { esconder, espiar, acenar, pensar, musica,
// Mais expressões: risada, bocejo, confusão, tontura, timidez e comemoração.
const pula = (n, alto, baixo, d) => Array.from({ length: n }, (_, i) => [{ dy: i % 2 ? baixo : alto }, d]);
const rir = [[{ forma: "feliz", boca: "aberta", esc: 1.08, dy: -4 }, 0.12], ...pula(8, -6, 2, 0.1),
[{ forma: "feliz", boca: "sorriso", dy: 0 }, 0.25], [{ forma: "aberto", boca: "neutra", esc: 1.0 }, 0.3]];
const bocejar = [[{ ab: 0.5, boca: "aberta", dy: -4, esc: 1.05 }, 0.5], [{ ab: 0.25, dy: -5 }, 1.0],
[{ ab: 0.0, boca: "neutra", dy: 2, esc: 1.0 }, 0.25], [{ ab: 1.0, dy: 0 }, 0.35]];
const confuso = [[{ ab2: 0.55, dx: -8, esc: 0.98 }, 0.25], [{ dx: 8 }, 0.45], [{ dx: -6 }, 0.4], [{}, 0.4],
[{ ab2: 1.0, dx: 0, esc: 1.0 }, 0.3]];
const balanca = Array.from({ length: 8 }, (_, i) => [{ dx: i % 2 ? -5 : 5, dy: i % 2 ? 2 : -2 }, 0.2]);
const tonto = [[{ esc: 1.05, boca: "aberta" }, 0.15], ...balanca, [{ dx: 0, dy: 0, esc: 1.0, boca: "neutra" }, 0.35]];
const timido = [[{ forma: "feliz", boca: "sorriso", dx: -14, dy: 9, esc: 0.9, cor: ROSA }, 0.35], [{}, 1.2],
[{ dx: 0, dy: 0, esc: 1.0, forma: "aberto", cor: CIANO }, 0.4]];
const comemorar = [[{ forma: "feliz", boca: "sorriso", cor: AMARELO, dy: -10, esc: 1.15 }, 0.15], [{ dy: 2, cor: ROSA }, 0.15],
[{ dy: -10, cor: VERDE }, 0.15], [{ dy: 2, cor: CIANO }, 0.15], [{ dy: -10, cor: AMARELO }, 0.15],
[{ dy: 0, cor: CIANO }, 0.2], [{ forma: "aberto", esc: 1.0 }, 0.3]];
return { rir, bocejar, confuso, tonto, timido, comemorar,
esconder, espiar, acenar, pensar, musica,
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre };
}
@@ -359,6 +451,85 @@ const r = {
const SEQS = sequencias();
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
/* Modo vida (servidor): o humor do bichinho escolhe os gestos soltos entre um ato e outro. */
const LIVRES_HUMOR = {
feliz: ["feliz", "alegre", "piscadinha", "coracao", "olhar_esq", "olhar_dir"],
tranquilo: ["olhar_esq", "olhar_dir", "feliz", "piscadinha", "olhar_baixo", "timido"],
bravo: ["bravo", "olhar_baixo", "olhar_esq", "olhar_dir", "confuso"],
entediado: ["olhar_esq", "olhar_dir", "olhar_baixo", "pensar", "bocejar", "confuso"],
carente: ["olhar_baixo", "triste", "olhar_esq", "olhar_dir", "timido"],
triste: ["triste", "olhar_baixo", "olhar_baixo"],
cansado: ["olhar_baixo", "piscar2", "bocejar"],
};
let HUMOR = "normal";
/* Modo vida: zanga (0 a 1) deixa o rosto emburrado aos poucos quando a alegria cai; sono profundo =
dormiu de cansaço, e aí som, movimento e câmera não acordam (como na madrugada). */
let ZANGA = 0;
let SONO_PROFUNDO = false;
/* Microexpressões acordado: de vez em quando a boca e as sobrancelhas mexem sozinhas (um canto
da boca sobe, uma sobrancelha arqueia, um suspiro) e voltam devagar ao neutro. O humor do modo
vida escolhe quais. Valores em "pixels de projeto" (multiplicados por ESC no desenho). */
function feicaoNeutra() { return { curva: 0, lado: 0, abre: 0, dx: 0, sob: [0, 0], inc: [0, 0] }; }
const FEICAO = { atual: feicaoNeutra(), alvo: feicaoNeutra(), prox: 0, volta: 0, t: 0 };
const ao = () => (Math.random() < 0.5 ? 1 : -1);
const MICRO = {
arquear: () => { const f = feicaoNeutra(), i = Math.random() < 0.5 ? 0 : 1; f.sob[i] = 12; f.inc[i] = -4; return f; },
interesse: () => Object.assign(feicaoNeutra(), { sob: [8, 8], abre: 0.35 }),
meioSorriso: () => Object.assign(feicaoNeutra(), { lado: ao(), curva: 0.1 }),
sorrisinho: () => Object.assign(feicaoNeutra(), { curva: 0.35, sob: [4, 4] }),
ladinho: () => { const d = ao(); return Object.assign(feicaoNeutra(), { dx: d, lado: d * 0.4, curva: -0.08 }); },
pensativo: () => Object.assign(feicaoNeutra(), { inc: [8, 8], sob: [-3, -3], dx: ao() * 0.7, curva: -0.15 }),
preocupado: () => Object.assign(feicaoNeutra(), { inc: [-9, -9], sob: [4, 4], curva: -0.3 }),
suspiro: () => Object.assign(feicaoNeutra(), { sob: [5, 5], abre: 0.6, curva: -0.05 }),
};
const MICRO_HUMOR = {
feliz: ["sorrisinho", "sorrisinho", "meioSorriso", "arquear", "interesse"],
tranquilo: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse"],
entediado: ["ladinho", "ladinho", "pensativo", "suspiro", "arquear"],
carente: ["preocupado", "preocupado", "suspiro", "meioSorriso"],
triste: ["preocupado", "suspiro", "pensativo"],
cansado: ["suspiro", "suspiro", "ladinho"],
bravo: ["pensativo", "pensativo", "ladinho", "suspiro"],
normal: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse", "pensativo", "suspiro"],
};
function atualizarFeicao(s) {
const dt = Math.min(0.1, Math.max(0, s - (FEICAO.t || s)));
FEICAO.t = s;
// só no rosto "de repouso": gesto com boca própria (sorriso, triste...) ou dormindo = neutro
const livre = !r.dormindo && !congelado && r.boca === "neutra";
if (!livre) {
FEICAO.alvo = feicaoNeutra();
FEICAO.prox = Math.max(FEICAO.prox, s + 1.5);
} else if (s >= FEICAO.prox) {
const lista = MICRO_HUMOR[HUMOR] || MICRO_HUMOR.normal;
FEICAO.alvo = MICRO[lista[Math.floor(Math.random() * lista.length)]]();
FEICAO.volta = s + 0.9 + Math.random() * 1.6;
FEICAO.prox = s + (2.5 + Math.random() * 5) * ({ cansado: 1.6, triste: 1.4, feliz: 0.8 }[HUMOR] || 1);
} else if (s >= FEICAO.volta) FEICAO.alvo = feicaoNeutra();
const k = 1 - Math.exp(-dt * 6), a = FEICAO.atual, b = FEICAO.alvo;
for (const c of ["curva", "lado", "abre", "dx"]) a[c] += (b[c] - a[c]) * k;
for (let i = 0; i < 2; i++) { a.sob[i] += (b.sob[i] - a.sob[i]) * k; a.inc[i] += (b.inc[i] - a.inc[i]) * k; }
}
/* Gestos soltos mais humanos: quase sempre coisas sutis (olhar de lado, piscar devagar, olhar para
baixo); de vez em quando um gesto maior do humor, nunca o mesmo duas vezes seguidas. Gestos
"para alguém" (acenar, tímido, piscadinha...) só quando tem gente por perto. */
const SUTIS = ["olhar_esq", "olhar_dir", "olhar_baixo", "piscar2", "olhar_esq", "olhar_dir"];
const PARA_ALGUEM = ["acenar", "coracao", "piscadinha", "timido", "esconder", "espiar", "surpreso"];
const RECENTES = [];
function alguemOlhando() {
const agora = performance.now() / 1000;
return (MOV.ativo && agora - MOV.ultimoMov < 30) || (TRACK.alvo && agora - TRACK.visto < 30);
}
function gestoSolto() {
const grande = Math.random() < ({ feliz: 0.3, entediado: 0.25, bravo: 0.3 }[HUMOR] || 0.18);
let lista = grande ? (LIVRES_HUMOR[HUMOR] || LIVRES) : SUTIS;
if (!alguemOlhando()) lista = lista.filter((g) => !PARA_ALGUEM.includes(g));
const opcoes = lista.filter((g) => !RECENTES.includes(g));
const g = (opcoes.length ? opcoes : SUTIS)[Math.floor(Math.random() * (opcoes.length || SUTIS.length))];
RECENTES.push(g); if (RECENTES.length > 2) RECENTES.shift();
return g;
}
function proximo() {
if (!r.fila.length) { r.kf = null; return; }
@@ -373,7 +544,22 @@ function proximo() {
r.kf_t = 0;
}
const ESPIRAL = { ate: 0 }; // tonto: espiral no lugar dos olhos até este instante
const espiralAtiva = () => performance.now() / 1000 < ESPIRAL.ate;
function caminhoEspiral(cx, cy, R, lado) {
const voltas = 2.75, giro = performance.now() / 1000 * 7 * lado;
let d = "M " + cx + "," + cy;
for (let th = 0.22; th <= voltas * Math.PI * 2; th += 0.22) {
const rr = R * th / (voltas * Math.PI * 2), a = th * lado + giro;
d += " L " + (cx + Math.cos(a) * rr).toFixed(1) + "," + (cy + Math.sin(a) * rr).toFixed(1);
}
return d;
}
function animar(nome) {
if (nome === "tonto") ESPIRAL.ate = performance.now() / 1000 + 2.1;
// mãos (esconder, espiar, acenar, pensar, bocejar) valem também quando o gesto vem sozinho
if (SEQS[nome] && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(nome);
if (SEQS[nome]) {
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
proximo();
@@ -397,6 +583,36 @@ function passo(dt) { // avanca a fila; true se ha gesto em
return true;
}
/* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
let ATIV = null;
const OLHAR = { x: 0, y: 0, ax: 0, ay: 0, prox: 0 }; // ponto que os olhos estão fitando no repouso
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"); }
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
}
/* Expressão criada no estúdio (criacao.py): receita já validada no servidor, só números e nomes de
peças. Vira uma sequência igual às de sequencias(), com mãos e espiral se a receita pedir. */
function corDaReceita(nome) {
return { claro: "claro", escuro: "escuro", rosa: ROSA, amarelo: AMARELO, verde: VERDE, vermelho: VERM }[nome] || CIANO;
}
function tocarExpressaoCriada(texto) {
let rc;
try { rc = JSON.parse(texto); } catch (e) { return; }
if (!rc || !Array.isArray(rc.passos) || !rc.passos.length) return;
if (r.dormindo) r.dormindo = false;
r.fila = rc.passos.slice(0, 12).map((p) => [{
forma: p.forma || "aberto", forma2: null, boca: p.boca || "neutra", cor: corDaReceita(p.cor),
dx: +p.dx || 0, dy: +p.dy || 0, esc: +p.esc || 1, ab: typeof p.ab === "number" ? p.ab : 1,
}, Math.max(0.05, Math.min(2, +p.duracao || 0.3))]);
proximo();
const total = r.fila.reduce((a, passo) => a + passo[1], 0);
if (rc.espiral) ESPIRAL.ate = performance.now() / 1000 + total;
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
}
function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
@@ -411,9 +627,28 @@ function repouso(t) { // pose calma: olha devagar, "respira
r.boca = "neutra";
r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
// o vaivem de sempre + a direcao de quem se mexeu na frente (se houver)
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
// olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
// centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
if (t >= OLHAR.prox || t < OLHAR.prox - 10) {
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.x += (OLHAR.ax - OLHAR.x) * 0.3;
OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
r.dx = OLHAR.x + 1.0 * Math.sin(t * 0.9) + MOV.olharX;
r.dy = OLHAR.y + 0.8 * Math.sin(t * 1.3) + MOV.olharY;
if (ATIV) { // fazendo algo: olha para a atividade
const v = ATIV.v;
r.dx = r.dx * (1 - v) * 0.3 + ATIV.x * v;
r.dy = r.dy * (1 - v) * 0.3 + ATIV.y * v;
if (ATIV.forma) r.forma = ATIV.forma;
if (ATIV.boca) r.boca = ATIV.boca;
if (ATIV.ab) r.ab = ATIV.ab;
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
r.forma = "bravo";
}
}
function desenhar() {
@@ -425,7 +660,7 @@ function desenhar() {
// desliza na direcao do movimento; parado, volta ao repouso discreto
const tAgora = performance.now() / 1000;
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
const mirando = MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2;
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 olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
@@ -433,7 +668,7 @@ function desenhar() {
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,
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo, espiral: espiralAtiva(),
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null,
iris: EXTRAS.iris,
@@ -458,13 +693,7 @@ function desenhar() {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2;
const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85;
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
brow.setAttribute('d', caminhoSobrancelha(i, esc));
brow.setAttribute('stroke', rgb(r.cor));
brow.setAttribute('stroke-width', 7 * ESC);
}
@@ -481,12 +710,7 @@ function tracosDoRosto(esc) {
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
}
if (EXTRAS.sobrancelhas && !r.dormindo) {
[E1, E2].forEach((c, i) => {
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inc = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
tracos.push({ d: 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele), largura: 9 * ESC });
});
for (let i = 0; i < 2; i++) tracos.push({ d: caminhoSobrancelha(i, esc), largura: 9 * ESC });
}
return tracos;
}
@@ -527,21 +751,43 @@ function trata(ordem) {
if (v.length === 3 && v.every((n) => !isNaN(n))) {
CIANO.splice(0, 3, ...v); r.cor = v;
}
} else if (ordem.indexOf("humor ") === 0) {
HUMOR = ordem.slice(6).trim();
} else if (ordem.indexOf("ritmo ") === 0) {
PERFIL = PERFIS[ordem.slice(6).trim()] || PERFIS.normal;
} else if (ordem.indexOf("expressao_criada ") === 0) {
tocarExpressaoCriada(ordem.slice(17));
} else if (ordem.indexOf("atividade_criada ") === 0) {
try { const rc = JSON.parse(ordem.slice(17)); atividade("receita", +rc.duracao || 25, rc); } catch (e) {}
} else if (ordem.indexOf("atividade ") === 0) {
const [nome, seg] = ordem.slice(10).trim().split(/\s+/); // "atividade bola 180"
atividade(nome, parseFloat(seg) || 0);
} else if (ordem.indexOf("zanga ") === 0) {
const z = parseFloat(ordem.slice(6));
if (!isNaN(z)) ZANGA = Math.max(0, Math.min(1, z));
} else if (ordem === "sono profundo" || ordem === "sono leve") {
SONO_PROFUNDO = ordem === "sono profundo";
} else if (ordem === "dormir" || ordem === "sono") {
r.dormindo = true;
atividade(null);
} else if (ordem === "acordar") {
if (window.carinhaSemServidor) { // sem a placa continua dormindo; o aviso chacoalha
const aviso = document.getElementById("semServidor");
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
return;
}
r.dormindo = false; animar("surpreso");
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
atividade(null);
} else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; }
}
@@ -582,27 +828,44 @@ async function buscarOrdens() {
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
conexaoOk();
const lista = await resp.json();
for (const nome of lista) trata(nome);
} catch (e) { /* sem servidor (file://) — ignora */ }
}
setInterval(buscarOrdens, 500);
// O app (window.CarinhaApp) guarda a ultima aparencia e os ajustes vindos do servidor:
// a copia do APK (file://) nao enxerga o armazenamento da pagina do servidor.
const LOCAL = location.protocol === "file:";
function lembrar(chave, valor) {
try { if (window.CarinhaApp && !LOCAL) window.CarinhaApp.guardar(chave, JSON.stringify(valor)); } catch (e) {}
}
function lembrado(chave) {
try { const s = window.CarinhaApp && window.CarinhaApp.lembrado(chave); return s ? JSON.parse(s) : null; } catch (e) { return null; }
}
// A aparencia guardada no servidor (rosto + cor) e aplicada ao carregar: o
// painel de controle personaliza e a carinha ja abre do jeito escolhido. Sem
// servidor (file://) isso falha calado e vale o padrao.
// servidor vale a ultima que o app guardou (ou o padrao).
async function aplicarAparencia() {
try {
const resp = await fetch("config", { cache: "no-store" });
if (!resp.ok) return;
const c = await resp.json();
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)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ }
let c = null;
if (!LOCAL) {
try {
const resp = await fetch("config", { cache: "no-store" });
if (resp.ok) c = await resp.json();
} catch (e) { /* sem servidor */ }
}
if (!c) c = lembrado("aparencia");
if (!c) return;
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)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
}
setTimeout(aplicarAparencia, 200);
// O painel troca rosto/cor por ordens: guarda a aparencia atual de tempos em tempos.
setInterval(() => { if (!window.carinhaSemServidor) lembrar("aparencia", { rosto: ROSTO_NOME, formato: FORMATO, cor: CIANO.slice() }); }, 15000);
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
@@ -613,6 +876,7 @@ if (MODO_PAINEL) {
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
@@ -626,9 +890,11 @@ if (MODO_PAINEL) {
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 (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; }
if (ATIV && !ATIV.saindo) { atividade(null); animar('surpreso'); cuidar('cutucar'); return; } // interrompeu
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); cuidar('carinho'); return; }
if (dur < 350 && Math.hypot(dx, dy) < 15) {
cuidar('cutucar');
animar('surpreso');
setTimeout(() => animar('feliz'), 700);
}
@@ -674,6 +940,49 @@ const MIC = {
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos
};
/* Acordar com som: precisa de N barulhos em 1 minuto (ajuste "Sons para acordar"). Na madrugada
silenciosa só barulho ALTO conta, e movimento/câmera não acordam (alguém se mexendo na cama). */
const DESPERTAR = { sons: 3, noite: true, ini: 23, fim: 7 };
function sonoRespeitado() { return madrugada() || SONO_PROFUNDO; }
function madrugada() {
if (!DESPERTAR.noite || DESPERTAR.ini === DESPERTAR.fim) return false;
const h = new Date().getHours();
return DESPERTAR.ini < DESPERTAR.fim ? h >= DESPERTAR.ini && h < DESPERTAR.fim : h >= DESPERTAR.ini || h < DESPERTAR.fim;
}
/* Perfil de sensibilidade = ritmo do modo vida (o servidor manda "ritmo calmo|normal|agitado").
Calmo precisa de vários estímulos para reagir e limiares mais altos; agitado reage a qualquer
som ou movimento. Multiplica os limiares dos Ajustes, sem mudar os Ajustes. */
const PERFIS = {
calmo: { lim: 1.5, reagir: 3, acordarSom: 2, acordarMov: 4, espera: 2, qualquer: false },
normal: { lim: 1.0, reagir: 1, acordarSom: 1, acordarMov: 2, espera: 1, qualquer: false },
agitado: { lim: 0.6, reagir: 1, acordarSom: 0.34, acordarMov: 1, espera: 0.5, qualquer: true },
};
let PERFIL = PERFIS.normal;
const limiar = (x) => Math.min(0.95, x * PERFIL.lim);
/* Conta o INÍCIO de cada estímulo (o mesmo som/movimento comprido vale um) numa janela de tempo
e diz quando chegou a `precisa`. */
function novoContador(janela) { return { janela, eventos: [], ultimo: -Infinity, estava: false, visto: -Infinity }; }
const CONTA = { somAcordar: novoContador(60), movAcordar: novoContador(30), somReagir: novoContador(20), movReagir: novoContador(20) };
function contar(c, ativo, agora, precisa, nome) {
// contador parado um tempo (ex.: o de "acordar" enquanto estava acordado): começa do zero
if (agora - c.visto > 2) c.estava = false;
c.visto = agora;
if (ativo && !c.estava && agora - c.ultimo >= 1.0) {
c.ultimo = agora;
c.eventos = c.eventos.filter((t) => agora - t < c.janela).concat(agora);
if (precisa > 1) console.log(nome + " " + c.eventos.length + "/" + precisa + (madrugada() ? " (madrugada)" : ""));
}
c.estava = ativo;
if (c.eventos.length >= precisa && agora - c.eventos[c.eventos.length - 1] < 1) { c.eventos = []; return true; }
return false;
}
/* Reação acordado: agitado varia; os outros se assustam. */
function reacaoEstimulo() {
if (!PERFIL.qualquer) return "surpreso";
const l = ["surpreso", "feliz", "alegre", "piscadinha", "surpreso"];
return l[Math.floor(Math.random() * l.length)];
}
function som(nivel) {
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
const agora = performance.now() / 1000;
@@ -688,8 +997,8 @@ function som(nivel) {
if (MIC.piso === null) MIC.piso = n;
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
const temSom = n >= MIC.limiarSom; // ruido audivel (absoluto)
const alto = n >= MIC.limiarAlto && (n - MIC.piso) >= MIC.ganho;
const temSom = n >= limiar(MIC.limiarSom); // ruido audivel (absoluto)
const alto = n >= limiar(MIC.limiarAlto) && (n - MIC.piso) >= limiar(MIC.ganho);
if (temSom) MIC.ultimoSom = agora;
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
@@ -698,15 +1007,19 @@ function som(nivel) {
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2));
}
if (r.dormindo && temSom) { // acordou: tinha barulho
if (r.dormindo) {
const precisa = Math.max(1, Math.round(DESPERTAR.sons * PERFIL.acordarSom));
if (contar(CONTA.somAcordar, sonoRespeitado() ? alto : temSom, agora, precisa, "ouvido: barulho")) { // acordou
r.dormindo = false;
animar("surpreso");
MIC.proxSurpresa = agora + MIC.cooldown;
console.log("ouvido: acordou nivel=" + n.toFixed(2));
relatarSom("acordou", n);
} else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) {
MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino
animar("surpreso");
}
} else if (contar(CONTA.somReagir, PERFIL.qualquer ? temSom : alto, agora, PERFIL.reagir, "ouvido: estimulo") &&
agora >= MIC.proxSurpresa) {
MIC.proxSurpresa = agora + MIC.cooldown * PERFIL.espera; // susto: barulho alto e repentino
animar(reacaoEstimulo());
console.log("ouvido: surpreso nivel=" + n.toFixed(2));
relatarSom("surpreso", n);
}
@@ -719,6 +1032,7 @@ function checarSilencio(agora) {
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
if (r.dormindo) return;
if (HUMOR !== "normal") return; // modo vida ligado: quem manda dormir é a energia
// movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
if (agora - atividade >= MIC.segSilencio) {
@@ -735,6 +1049,15 @@ function relatarSom(evento, n) {
if (evento !== "adormeceu") registrarEvento("som", evento, n);
}
/* Toque na tela vira cuidado no modo vida (carinho/cutucada). Só o celular conta. */
function cuidar(acao) {
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
try {
fetch("/vida/cuidar", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ acao }) }).catch(() => {});
} catch (e) {}
}
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
function registrarEvento(tipo, evento, nivel) {
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
@@ -781,7 +1104,7 @@ function rastrear(t) {
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
if(t.atividade===true) {
MOV.ultimoMov=agora;
if(r.dormindo) { r.dormindo=false;animar('surpreso'); }
if(r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) { r.dormindo=false;animar('surpreso'); }
}
}
function movimento(vel, cx, cy) {
@@ -795,7 +1118,7 @@ function movimento(vel, cx, cy) {
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
if (TRACK.alvo && agora-TRACK.visto<.35) return;
const mexeu = v >= MOV.limiar;
const mexeu = v >= limiar(MOV.limiar);
if (mexeu) MOV.ultimoMov = agora;
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
@@ -810,17 +1133,21 @@ function movimento(vel, cx, cy) {
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
}
const forte = v >= MOV.limiarForte;
if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente
const forte = v >= limiar(MOV.limiarForte);
if (r.dormindo) {
// acordou: algo se mexeu na frente (de dia; calmo precisa de vários movimentos)
if (!sonoRespeitado() && contar(CONTA.movAcordar, mexeu, agora, PERFIL.acordarMov, "presenca: movimento")) {
r.dormindo = false;
animar("surpreso");
MOV.proxSurpresa = agora + MOV.cooldown;
console.log("presenca: acordou nivel=" + v.toFixed(2));
relatarMov("acordou", v);
} else if (forte && !MOV.estavaForte && agora >= MOV.proxSurpresa) {
}
} else if (contar(CONTA.movReagir, PERFIL.qualquer ? mexeu : forte, agora, PERFIL.reagir, "presenca: estimulo") &&
agora >= MOV.proxSurpresa) {
// susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
MOV.proxSurpresa = agora + MOV.cooldown;
animar("surpreso");
MOV.proxSurpresa = agora + MOV.cooldown * PERFIL.espera;
animar(reacaoEstimulo());
console.log("presenca: surpreso nivel=" + v.toFixed(2));
relatarMov("surpreso", v);
}
@@ -964,6 +1291,10 @@ function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
SONO.apagar = c.apagarTela !== false;
if (typeof c.somAcordar === 'number') DESPERTAR.sons = Math.max(1, Math.round(c.somAcordar));
DESPERTAR.noite = c.noiteSilenciosa !== false;
if (typeof c.noiteInicio === 'number') DESPERTAR.ini = Math.round(c.noiteInicio);
if (typeof c.noiteFim === 'number') DESPERTAR.fim = Math.round(c.noiteFim);
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
AUTO = c.auto;
@@ -975,6 +1306,7 @@ function configurarApp(c) {
MOV.cooldown = c.movcooldown; MOV.olharMax = c.movolhar;
if (!c.reagir) { MOV.olharX = MOV.olharY = 0; MOV.ultimoMov = 0; }
configRecebida = c;
lembrar('config', c);
if(!c.seguirRastreamento || !c.sensorCamera || !c.reagir) TRACK.alvo=null;
}
let sincronizando = false;
@@ -996,6 +1328,7 @@ async function sincronizarApp() {
centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
atividade: window.MotorRosto && window.MotorRosto.atividadeAtual ? window.MotorRosto.atividadeAtual() : null,
bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo,
@@ -1093,7 +1426,13 @@ function laco(t) {
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
checarSilencio(t / 1000);
atualizarFeicao(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);
document.body.classList.toggle("atividade", !!ATIV && !ATIV.saindo);
if (window.carinhaSemServidor && !r.dormindo) { r.fila = []; r.kf = null; r.dormindo = true; } // sem a placa: só dorme
const ocupado = congelado ? true : passo(dt);
if (!ocupado) {
descanso += dt;
@@ -1103,15 +1442,18 @@ function laco(t) {
} else if (t >= proxPiscar) {
animar("piscar");
proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto) {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
proxGesto = t + 7000 + Math.random() * 8000;
} else if (AUTO && t >= proxGesto && !ATIV) {
animar(gestoSolto());
// cansado e triste mexem menos; feliz, mais
const ritmo = { cansado: 1.8, triste: 1.5, feliz: 0.8 }[HUMOR] || 1;
proxGesto = t + (7000 + Math.random() * 8000) * ritmo;
}
} else if (t >= proxGesto) {
proxGesto = t + 7000 + Math.random() * 8000;
}
desenhar();
if (window.MotorRosto && window.MotorRosto.palco) window.MotorRosto.palco({ t: t / 1000, cor: CIANO });
contador++;
if (t - marco >= 1000) {
@@ -1162,6 +1504,81 @@ setTimeout(relatar, 1500);
setInterval(poll,1000);poll();
})();
/* ---------------------------------------------------------------- sem servidor
Sem a placa (copia do APK, ou o servidor caiu com a pagina aberta) a carinha dorme com a
ultima aparencia e ajustes guardados e mostra placa ✕ celular. Volta sozinha quando a placa
responde (o app recarrega a pagina do servidor; aberta pelo servidor, o SSE reconecta). */
let ultimoOk = performance.now();
const TOLERANCIA_SEM_SERVIDOR = 15000; // ms sem resposta antes de dormir
function conexaoOk() { ultimoOk = performance.now(); }
function mostrarSemServidor(ligar) {
let el = document.getElementById("semServidor");
document.body.classList.toggle("semServidor", ligar);
if (!ligar) { if (el) el.hidden = true; return; }
if (!el) {
el = document.createElement("div");
el.id = "semServidor";
// placa e celular pelo canto de cima à esquerda; o desenho vertical vai no canto direito com o celular deitado
const placa = (px, py) =>
'<g class="placa">' +
'<rect x="' + px + '" y="' + py + '" width="84" height="56" rx="6"/>' +
'<rect class="chip" x="' + (px + 29) + '" y="' + (py + 15) + '" width="26" height="26" rx="3"/>' +
[0,1,2,3,4,5,6,7].map((i) => '<circle class="pino" cx="' + (px + 11 + i * 9) + '" cy="' + (py + 7) + '" r="2"/>').join("") +
'<circle class="pino" cx="' + (px + 7) + '" cy="' + (py + 50) + '" r="2.4"/><circle class="pino" cx="' + (px + 77) + '" cy="' + (py + 50) + '" r="2.4"/>' +
'<circle class="led" cx="' + (px + 72) + '" cy="' + (py + 22) + '" r="2.6"/>' +
'</g>';
const celular = (cx, cy) =>
'<g class="celular">' +
'<rect x="' + cx + '" y="' + cy + '" width="40" height="76" rx="8"/>' +
'<g class="olhinhos"><ellipse cx="' + (cx + 13) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/><ellipse cx="' + (cx + 27) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/></g>' +
'<path class="boquinha" d="M' + (cx + 14) + ' ' + (cy + 48) + ' Q' + (cx + 20) + ' ' + (cy + 44) + ' ' + (cx + 26) + ' ' + (cy + 48) + '"/>' +
'</g>';
const xis = (x, y) => '<g class="xis"><path d="M' + (x - 10) + ' ' + (y - 10) + ' L' + (x + 10) + ' ' + (y + 10) + ' M' + (x + 10) + ' ' + (y - 10) + ' L' + (x - 10) + ' ' + (y + 10) + '"/></g>';
el.innerHTML =
'<svg class="horizontal" viewBox="0 0 300 104" aria-hidden="true">' + placa(18, 30) +
'<path class="cabo" d="M104 58 H146"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M212 50 A10 10 0 0 0 212 66"/>' +
'<path class="arco a2" d="M203 44 A18 18 0 0 0 203 72"/>' +
'<path class="arco a3" d="M194 38 A26 26 0 0 0 194 78"/>' +
'</g>' + xis(160, 58) + celular(220, 18) +
'</svg>' +
'<svg class="vertical" viewBox="0 0 120 262" aria-hidden="true">' + placa(18, 8) +
'<path class="cabo" d="M60 66 V108"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M52 172 A10 10 0 0 1 68 172"/>' +
'<path class="arco a2" d="M46 163 A18 18 0 0 1 74 163"/>' +
'<path class="arco a3" d="M40 154 A26 26 0 0 1 80 154"/>' +
'</g>' + xis(60, 122) + celular(40, 182) +
'</svg>' +
'<div class="titulo">sem conexão com a placa</div>' +
'<div class="sub"></div>';
document.body.appendChild(el);
}
let alvo = "";
try { alvo = (window.CarinhaApp && window.CarinhaApp.servidor()) || (LOCAL ? "" : location.host); } catch (e) {}
el.querySelector(".sub").textContent = "tentando de novo" + (alvo ? " · " + String(alvo).replace(/^https?:\/\//, "") : "") + "…";
el.style.color = "rgb(" + CIANO.map(Math.round).join(",") + ")";
el.hidden = false;
}
function checarServidor() {
if (MODO_PAINEL || par.get("espelho") === "1") return;
if (window.carinhaSSE) conexaoOk();
const fora = LOCAL || performance.now() - ultimoOk > TOLERANCIA_SEM_SERVIDOR;
if (window.carinhaSemServidor) mostrarSemServidor(true); // acompanha a cor guardada
if (fora === !!window.carinhaSemServidor) return;
window.carinhaSemServidor = fora;
console.log(fora ? "sem servidor: dormindo" : "servidor de volta");
mostrarSemServidor(fora);
if (fora) { atividade(null); r.fila = []; r.kf = null; r.dormindo = true; }
else trata("acordar");
}
// Copia do APK: os ultimos ajustes guardados (boca, sobrancelhas, sono...) valem também aqui.
setTimeout(() => { if (LOCAL) { const c = lembrado("config"); if (c) configurarApp(c); } checarServidor(); }, 300);
setInterval(checarServidor, 1000);
</script>
<script src="/reacoes-render.js"></script>
<script>
+863 -2
View File
@@ -107,7 +107,20 @@
}
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes).
// Tonto: uma espiral girando no lugar de cada olho (cada um gira para um lado, como no emoji 😵‍💫).
function espiral(c, o, cx, cy, R, lado) {
const voltas = 2.75, giro = (o.t || 0) * 7 * lado;
c.save(); c.strokeStyle = css(o.cor); c.lineWidth = Math.max(2, R * 0.17); c.lineCap = 'round';
c.beginPath();
for (let th = 0; th <= voltas * Math.PI * 2; th += 0.22) {
const rr = R * th / (voltas * Math.PI * 2), a = th * lado + giro;
if (th) c.lineTo(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr); else c.moveTo(cx, cy);
}
c.stroke(); c.restore();
}
function olhoPlano(c, o, p, est, e) {
if (o.espiral) { espiral(c, o, p.cx, p.cy, Math.min(est.w, est.h) * p.ry * 0.5, p.lado || 1); return; }
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 };
@@ -162,6 +175,7 @@
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) {
if (o.espiral) { espiral(c, o, p.cx, p.cy, est.w * p.ry * 0.5, p.lado || 1); return; }
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();
@@ -289,7 +303,7 @@
// ------------------------------------------------------------------ mãos
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências
// de rosto de mesmo nome no carinha.html.
const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 };
const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8, bocejar: 2.1 };
const maos = { modo: null, t0: null };
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); };
const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
@@ -387,6 +401,16 @@
const y = fora + (y0 - fora) * v;
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0;
mao(c, o, x, y, s * 0.95, -0.18 + balanco, -1, 0, 1, false, true);
} else if (maos.modo === 'bocejar') {
// mão na frente da boca, com tapinhas, e os Zzz subindo do olho
const ida = voltinha(k / 0.25), volta = faixaDe(k, 0.78, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
const bx = (e.cx + d.cx) / 2 + e.ry * 0.12, by = e.cy + e.ry * 1.2;
const tapa = k > 0.3 && k < 0.75 ? Math.abs(Math.sin((k - 0.3) * Math.PI * 6)) * s * 0.12 : 0;
mao(c, o, bx + e.ry * 0.5 * (1 - Math.min(1, v)), fora + (by + s * 0.55 - fora) * v + tapa, s * 0.9, -0.22, -1, 0.15, 0, false, true, true);
c.save(); c.globalAlpha *= Math.min(1, v);
desenharZzz(c, Object.assign({}, o, { dormindo: true }), olhos, 0.8);
c.restore();
} else if (maos.modo === 'pensar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
@@ -518,6 +542,838 @@
c.restore();
}
// ------------------------------------------------------------ atividades
// "Momentos dele": um bonequinho do Carinha (silhueta na cor dos olhos) faz algo na parte de
// baixo da tela enquanto os olhos sobem, encolhem e acompanham. Camada própria, atrás dos olhos,
// vale para qualquer rosto (SVG ou motor). O carinha.html pergunta para onde olhar
// (olharAtividade) e chama palco() a cada quadro.
const ATIVIDADES = {
ler: { nome: 'Lendo', emoji: '📖', dur: [25, 45] },
cantar: { nome: 'Cantando', emoji: '🎤', dur: [18, 30] },
bola: { nome: 'Jogando bola', emoji: '⚽', dur: [20, 35] },
videogame: { nome: 'Videogame', emoji: '🎮', dur: [25, 45] },
pintar: { nome: 'Pintando', emoji: '🎨', dur: [25, 40] },
malhar: { nome: 'Malhando', emoji: '🏋️', dur: [15, 25] },
dormir: { nome: 'Cochilando', emoji: '🛏️', dur: [40, 80] },
cafe: { nome: 'Tomando café', emoji: '☕', dur: [20, 35] },
cozinhar: { nome: 'Cozinhando', emoji: '🍳', dur: [20, 35] },
regar: { nome: 'Regando planta', emoji: '🪴', dur: [20, 30] },
violao: { nome: 'Tocando violão', emoji: '🎸', dur: [20, 35] },
dancar: { nome: 'Dançando', emoji: '💃', dur: [15, 30] },
corda: { nome: 'Pulando corda', emoji: '🪢', dur: [15, 25] },
meditar: { nome: 'Meditando', emoji: '🧘', dur: [25, 45] },
telescopio: { nome: 'Olhando estrelas', emoji: '🔭', dur: [25, 40] },
pipa: { nome: 'Soltando pipa', emoji: '🪁', dur: [20, 35] },
bolhas: { nome: 'Bolhas de sabão', emoji: '🫧', dur: [18, 30] },
skate: { nome: 'Andando de skate', emoji: '🛹', dur: [18, 30] },
computador: { nome: 'No computador', emoji: '💻', dur: [25, 45] },
pescar: { nome: 'Pescando', emoji: '🎣', dur: [30, 50] },
receita: { nome: 'Criada por IA', emoji: '✨', dur: [20, 40] }, // receita do estúdio de criação
};
const ativ = { nome: null, t0: 0, dur: 0, fim: 0, receita: null };
let palcoTela = null, pctx = null;
const agoraS = () => performance.now() / 1000;
function iniciarAtividade(nome, segundos, receita) {
if (!ATIVIDADES[nome] || (nome === 'receita' && !receita)) { pararAtividade(); return false; }
const [a, b] = ATIVIDADES[nome].dur;
ativ.nome = nome; ativ.t0 = agoraS(); ativ.fim = 0; ativ.receita = receita || null;
ativ.dur = segundos || a + Math.random() * (b - a);
return true;
}
function pararAtividade() { if (ativ.nome && !ativ.fim) ativ.fim = agoraS(); }
// 0..1: entra e sai em 0,6 s; termina sozinha depois da duração
function visibilidade(t) {
if (!ativ.nome) return 0;
if (!ativ.fim && t - ativ.t0 >= ativ.dur) ativ.fim = t;
const entra = Math.min(1, Math.max(0, (t - ativ.t0) / 0.6));
if (!ativ.fim) return entra;
const sai = 1 - (t - ativ.fim) / 0.6;
if (sai <= 0) { ativ.nome = null; return 0; }
return Math.min(entra, sai);
}
// movimentos de cada cena, em função do tempo k (s) desde o início
const bola = (k) => { const p = (k % 1.1) / 1.1; return { p, h: 4 * p * (1 - p), x: 0.35 * Math.sin(k * 0.7) }; };
const pincel = (k) => ({ x: Math.sin(k * 3.1) * 0.6 + Math.sin(k * 1.3) * 0.4, y: Math.cos(k * 2.3) * 0.7 });
const forca = (k) => (1 - Math.cos(k * 2.2)) / 2; // 0 = halteres no ombro, 1 = braços em cima
/** Para onde os olhos grandes olham (unidades do rosto: dx ±24, dy ±12) e que cara fazem. */
function olharAtividade(t) {
const v = visibilidade(t);
if (!v) return null;
const k = t - ativ.t0, info = { nome: ativ.nome, v, x: 0, y: 0, saindo: !!ativ.fim };
const lado = innerWidth >= innerHeight * 1.2 ? 1 : 0; // deitado: a cena fica à direita
if (ativ.nome === 'ler') { // lê linha a linha: varre da esquerda para a direita
const linha = (k % 2.4) / 2.4;
info.x = (linha < 0.9 ? -8 + 16 * (linha / 0.9) : 8 - 16 * ((linha - 0.9) / 0.1)) + lado * 13;
info.y = 7 + (Math.floor(k / 2.4) % 3) * 1.5;
} else if (ativ.nome === 'cantar') {
info.x = 6 * Math.sin(k * 1.3) + lado * 12; info.y = -4 + 4 * Math.sin(k * 4);
info.forma = 'feliz'; info.boca = Math.sin(k * 7) > -0.2 ? 'aberta' : 'sorriso';
} else if (ativ.nome === 'bola') { // segue a bola
const b = bola(k); info.x = b.x * 16 + lado * 14; info.y = 9 - b.h * 18;
} else if (ativ.nome === 'videogame') { // vidrado na TV; de vez em quando comemora
info.x = 18 + lado * 5 + Math.sin(k * 9) * 1.5; info.y = 1;
if (k % 9 > 7.6) { info.forma = 'feliz'; info.boca = 'sorriso'; }
} else if (ativ.nome === 'pintar') {
const p = pincel(k); info.x = 16 + lado * 5 + p.x * 4; info.y = -1 + p.y * 5;
} else if (ativ.nome === 'malhar') {
const f = forca(k); info.x = lado * 14; info.y = 6 - f * 12; info.ab = f > 0.85 ? 0.55 : 1;
} else if (ativ.nome === 'receita') {
const R = ativ.receita || {}, w = Math.PI * 2 * (R.ritmo || 0.8);
const o = (R.objetos || [])[0];
if (R.olhar === 'cima') { info.x = lado * 12; info.y = -10; }
else if (R.olhar === 'baixo') { info.x = lado * 10; info.y = 8; }
else if (R.olhar === 'lado') { info.x = lado * 20; info.y = 2; }
else if (R.olhar === 'frente') { info.x = 4 * Math.sin(k * 0.7); info.y = 0; }
else { // segue o primeiro objeto
info.x = lado * 14 + Math.sin(k * w) * 5;
info.y = o && o.movimento === 'pular' ? 8 - Math.abs(Math.sin(k * w * 0.5)) * 16 : o && o.movimento === 'subir' ? -6 : 3;
}
if (R.forma && R.forma !== 'aberto') info.forma = R.forma;
if (R.boca && R.boca !== 'neutra') info.boca = R.boca;
} else if (ativ.nome === 'dormir') {
info.forma = 'dormir'; info.boca = 'sono'; info.x = lado * 6; info.y = 4;
} else if (ativ.nome === 'cafe') {
const f = (k % 5) / 5, gole = f > 0.55 && f < 0.92;
info.x = lado * 12; info.y = gole ? -3 : 3; if (gole) info.forma = 'feliz';
} else if (ativ.nome === 'cozinhar') {
const cc = (k % 2.6) / 2.6, h = cc < 0.55 ? 4 * (cc / 0.55) * (1 - cc / 0.55) : 0;
info.x = lado * 16; info.y = 4 - h * 14;
} else if (ativ.nome === 'regar') {
info.x = lado * 18; info.y = 8;
} else if (ativ.nome === 'violao') {
info.x = lado * 8 + 3 * Math.sin(k * 1.1); info.y = 6 + 1.5 * Math.sin(k * 2.2); info.boca = 'sorriso';
if (Math.sin(k * 0.7) > 0.3) info.forma = 'feliz';
} else if (ativ.nome === 'dancar') {
info.x = lado * 10 + 6 * Math.sin(k * 5); info.y = -2 + 4 * Math.abs(Math.sin(k * 5)); info.forma = 'feliz'; info.boca = 'sorriso';
} else if (ativ.nome === 'corda') {
const th = k * Math.PI * 2 * 0.85; info.x = lado * 12; info.y = 4 - Math.pow(Math.max(0, Math.cos(th)), 0.6) * 10;
} else if (ativ.nome === 'meditar') {
info.forma = 'dormir'; info.boca = 'sorriso'; info.x = lado * 6; info.y = -2 + 2 * Math.sin(k * 1.2);
} else if (ativ.nome === 'telescopio') {
info.x = lado * 16; info.y = -11;
} else if (ativ.nome === 'pipa') {
info.x = lado * 14 + 4 * Math.sin(k * 0.8); info.y = -11 + 2 * Math.cos(k * 1.1);
} else if (ativ.nome === 'bolhas') {
info.x = lado * 14 + 4 * Math.sin(k * 0.9); info.y = -8 + 3 * Math.sin(k * 0.6);
} else if (ativ.nome === 'skate') {
info.x = lado * 12 + 7 * Math.sin(k * 0.9); info.y = 6;
} else if (ativ.nome === 'computador') {
info.x = lado * 18 + Math.sin(k * 20) * 0.6; info.y = 2 + ((k * 2.5) % 1) * 1.5;
} else if (ativ.nome === 'pescar') {
const cc = k % 8; info.x = lado * 18; info.y = 7;
if (cc > 6.2 && cc < 7.6) { info.forma = 'feliz'; info.boca = 'sorriso'; info.y = 0; }
}
return info;
}
// --- o bonequinho: cabeça-tela com olhinhos, antena, tronco, braços e pernas articulados.
// Ângulos em radianos: 0 = apontando para baixo; positivo gira para a direita da tela.
function membro(c, x0, y0, a1, a2, l1, l2, w) {
const x1 = x0 + Math.sin(a1) * l1, y1 = y0 + Math.cos(a1) * l1;
const x2 = x1 + Math.sin(a1 + a2) * l2, y2 = y1 + Math.cos(a1 + a2) * l2;
c.beginPath(); capsula(c, x0, y0, x1, y1, w / 2); c.fill(); c.stroke();
c.beginPath(); capsula(c, x1, y1, x2, y2, w / 2); c.fill(); c.stroke();
return { x: x2, y: y2 };
}
function boneco(c, x, y, s, p, cor, k) {
c.fillStyle = css(cor.map((v) => v * 0.22), 0.96); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.lineJoin = 'round'; c.lineCap = 'round';
const w = s * 0.27, braco = [s * 0.62, s * 0.58];
const perna = p.sentado ? [s * 0.38, s * 0.62] : [s * 0.72, s * 0.7];
const hx = x + (p.dx || 0) * s, hy = p.sentado ? y - perna[1] - s * 0.12 : y - (perna[0] + perna[1]) * 0.97;
const pE = p.pE || [-0.08, 0], pD = p.pD || [0.08, 0];
membro(c, hx - s * 0.25, hy, pE[0], pE[1], perna[0], perna[1], w * 1.1);
membro(c, hx + s * 0.25, hy, pD[0], pD[1], perna[0], perna[1], w * 1.1);
c.save();
c.translate(hx, hy); c.rotate(-(p.inclina || 0));
// tronco
caixa(c, -s * 0.5, -s * 1.12, s, s * 1.12, s * 0.25); c.fill(); c.stroke();
c.save(); c.fillStyle = css(cor, 0.85); // luzinha no peito
c.beginPath(); c.arc(0, -s * 0.62, s * 0.09 * (0.8 + 0.2 * Math.sin(k * 3)), 0, Math.PI * 2); c.fill(); c.restore();
// cabeça (uma telinha, como o celular) com olhinhos e antena
c.save(); c.translate(0, -s * 1.25); c.rotate(-(p.cabeca || 0));
c.beginPath(); c.moveTo(0, -s * 0.95); c.lineTo(0, -s * 1.25); c.stroke();
c.save(); c.fillStyle = css(cor); c.beginPath(); c.arc(0, -s * 1.3, s * 0.1, 0, Math.PI * 2); c.fill(); c.restore();
caixa(c, -s * 0.66, -s * 0.98, s * 1.32, s * 0.98, s * 0.28); c.fill(); c.stroke();
const ox = (p.olhoX || 0) * s * 0.12, oy = (p.olhoY || 0) * s * 0.1;
const pisca = (k % 3.7) < 0.12 ? 0.15 : 1;
c.save(); c.fillStyle = css(cor); c.strokeStyle = css(cor);
for (const lado of [-1, 1]) {
if (p.fechados) { c.lineWidth = Math.max(1.5, s * 0.07); c.beginPath(); c.arc(lado * s * 0.27, -s * 0.52, s * 0.12, 0.2, Math.PI - 0.2); c.stroke(); continue; }
c.beginPath(); c.ellipse(lado * s * 0.27 + ox, -s * 0.5 + oy, s * 0.11, s * 0.16 * pisca, 0, 0, Math.PI * 2); c.fill();
}
c.restore(); c.restore();
// braços (no mesmo quadro do tronco inclinado); as mãos voltam para segurar os objetos
const bE = p.bE || [-0.25, 0.15], bD = p.bD || [0.25, -0.15];
const mE = membro(c, -s * 0.58, -s * 0.98, bE[0], bE[1], braco[0], braco[1], w);
const mD = membro(c, s * 0.58, -s * 0.98, bD[0], bD[1], braco[0], braco[1], w);
c.restore();
const rot = (pt) => { // do quadro do tronco para a tela
const a = -(p.inclina || 0), cs = Math.cos(a), sn = Math.sin(a);
return { x: hx + pt.x * cs - pt.y * sn, y: hy + pt.x * sn + pt.y * cs };
};
return { mE: rot(mE), mD: rot(mD), quadril: { x: hx, y: hy }, cabeca: rot({ x: 0, y: -s * 1.75 }) };
}
const claro = (cor, a) => css(cor.map((v) => v + (255 - v) * 0.55), a);
const escuro = (cor) => css(cor.map((v) => v * 0.25));
function nota(c, x, y, s) { // ♪
c.beginPath(); c.ellipse(x, y, s * 0.2, s * 0.15, -0.4, 0, Math.PI * 2); c.fill();
c.fillRect(x + s * 0.14, y - s * 0.7, s * 0.06, s * 0.7);
c.beginPath(); c.moveTo(x + s * 0.2, y - s * 0.7); c.quadraticCurveTo(x + s * 0.5, y - s * 0.55, x + s * 0.42, y - s * 0.3); c.stroke();
}
const ROSA_ = [255, 112, 176], AMARELO_ = [255, 218, 96], VERDE_ = [120, 255, 160], LARANJA_ = [255, 150, 70];
const SENTADO = { sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35] };
const rnd = (i, sem) => { const a = Math.sin(i * 12.9898 + sem * 78.233) * 43758.5453; return a - Math.floor(a); };
function estrela(c, x, y, r) {
c.beginPath();
for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5, rr = i % 2 ? r * 0.45 : r; c.lineTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr); }
c.closePath(); c.fill();
}
function vapor(c, x, y, s, k, cor) { // três fios subindo e ondulando
c.save(); c.strokeStyle = css(cor, 0.7); c.lineWidth = Math.max(1, s * 0.04);
const alfa = c.globalAlpha;
for (let i = 0; i < 3; i++) {
const f = (k * 0.5 + i / 3) % 1, bx = x + (i - 1) * s * 0.1, by = y - f * s * 0.7;
c.globalAlpha = alfa * (1 - f) * Math.min(1, f / 0.2);
c.beginPath(); c.moveTo(bx, by); c.bezierCurveTo(bx + s * 0.1, by - s * 0.12, bx - s * 0.1, by - s * 0.24, bx, by - s * 0.36); c.stroke();
}
c.restore();
}
function xicara(c, x, y, s, cor) {
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.moveTo(x - s * 0.18, y - s * 0.2); c.lineTo(x + s * 0.18, y - s * 0.2); c.lineTo(x + s * 0.14, y + s * 0.08); c.lineTo(x - s * 0.14, y + s * 0.08); c.closePath(); c.fill(); c.stroke();
c.beginPath(); c.arc(x + s * 0.2, y - s * 0.07, s * 0.08, -1.4, 1.4); c.stroke();
}
function chama(c, x, y, alt, s) {
c.fillStyle = css(LARANJA_);
c.beginPath(); c.moveTo(x, y - alt); c.quadraticCurveTo(x + s * 0.13, y - alt * 0.3, x, y); c.quadraticCurveTo(x - s * 0.13, y - alt * 0.3, x, y - alt); c.fill();
c.fillStyle = css(AMARELO_);
c.beginPath(); c.moveTo(x, y - alt * 0.6); c.quadraticCurveTo(x + s * 0.06, y - alt * 0.2, x, y); c.quadraticCurveTo(x - s * 0.06, y - alt * 0.2, x, y - alt * 0.6); c.fill();
}
function zzz(c, x, y, s, k, cor) { // Zs subindo
c.save(); c.strokeStyle = css(cor); c.lineCap = 'round'; c.lineJoin = 'round';
const alfa = c.globalAlpha;
for (let i = 0; i < 3; i++) {
const f = (k * 0.3 + i / 3) % 1, tam = s * (0.18 + 0.2 * f), zx = x - s * 0.5 * f, zy = y - s * 1.3 * f;
c.globalAlpha = alfa * Math.min(1, f / 0.15) * (1 - f); c.lineWidth = Math.max(1.5, tam * 0.2);
c.beginPath(); c.moveTo(zx - tam / 2, zy - tam / 2); c.lineTo(zx + tam / 2, zy - tam / 2); c.lineTo(zx - tam / 2, zy + tam / 2); c.lineTo(zx + tam / 2, zy + tam / 2); c.stroke();
}
c.restore();
}
function notas(c, x, y, s, k, cor, dir) {
c.fillStyle = css(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
const alfa = c.globalAlpha;
for (let i = 0; i < 3; i++) {
const f = (k * 0.45 + i / 3) % 1;
c.globalAlpha = alfa * Math.min(1, f / 0.15) * (1 - f);
nota(c, x + dir * s * (0.3 + 0.5 * i) + Math.sin(f * 6 + i) * s * 0.2, y - f * s * 1.6, s * 0.7);
}
c.globalAlpha = alfa;
}
// ---- objetos que as receitas podem usar (mesmos desenhos das cenas feitas à mão)
function desenharObjeto(c, tipo, ox, oy, s, k, cor, giro, j) {
c.save(); c.translate(ox, oy); if (giro) c.rotate(giro);
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
if (tipo === 'bola') {
const r = s * 0.28; c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3)); c.beginPath();
for (let i = 0; i < 5; i++) { const a = i * 1.2566; c.lineTo(Math.cos(a) * r * 0.38, Math.sin(a) * r * 0.38); }
c.closePath(); c.fill();
} else if (tipo === 'livro') {
const lw = s * 0.62, lh = s * 0.5;
for (const lado of [-1, 1]) { c.beginPath(); c.moveTo(0, lh * 0.55); c.lineTo(lado * lw, lh * 0.45); c.lineTo(lado * lw, -lh * 0.45); c.lineTo(0, -lh * 0.35); c.closePath(); c.fill(); c.stroke(); }
} else if (tipo === 'xicara') {
xicara(c, 0, 0, s, cor); vapor(c, 0, -s * 0.3, s, k, cor);
} else if (tipo === 'nota') {
c.fillStyle = css(cor); nota(c, 0, 0, s * 0.7);
} else if (tipo === 'estrela') {
c.fillStyle = css(j % 2 ? AMARELO_ : cor); estrela(c, 0, 0, s * 0.16);
} else if (tipo === 'coracao') {
c.fillStyle = css(ROSA_); coracao(c, 0, 0, s * 0.22);
} else if (tipo === 'zzz') {
zzz(c, 0, 0, s, k + j, cor);
} else if (tipo === 'chama') {
chama(c, 0, s * 0.15, s * (0.35 + 0.08 * Math.sin(k * 17 + j)), s);
} else if (tipo === 'bolha') {
const r = s * (0.13 + 0.1 * rnd(j, 4)); c.fillStyle = css(cor, 0.1);
c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.strokeStyle = claro(cor, 1); c.beginPath(); c.arc(0, 0, r * 0.65, -2.4, -1.6); c.stroke();
} else if (tipo === 'halter') {
c.fillRect(-s * 0.25, -s * 0.035, s * 0.5, s * 0.07);
for (const lado of [-1, 1]) { caixa(c, lado * s * 0.28 - s * 0.07, -s * 0.17, s * 0.14, s * 0.34, s * 0.04); c.fill(); c.stroke(); }
} else if (tipo === 'microfone') {
c.fillStyle = escuro(cor); c.beginPath(); capsula(c, 0, 0, -s * 0.1, -s * 0.4, s * 0.07); c.fill(); c.stroke();
c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(-s * 0.12, -s * 0.5, s * 0.14, 0, Math.PI * 2); c.fill(); c.stroke();
} else if (tipo === 'controle') {
caixa(c, -s * 0.42, -s * 0.16, s * 0.84, s * 0.34, s * 0.16); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3)); c.fillRect(-s * 0.3, -s * 0.02, s * 0.16, s * 0.05); c.fillRect(-s * 0.245, -s * 0.08, s * 0.05, s * 0.17);
} else if (tipo === 'pincel') {
c.beginPath(); c.moveTo(-s * 0.1, s * 0.08); c.lineTo(s * 0.4, -s * 0.12); c.stroke();
c.fillStyle = css([ROSA_, AMARELO_, VERDE_][Math.floor(k) % 3]); c.beginPath(); c.arc(s * 0.43, -s * 0.13, s * 0.07, 0, Math.PI * 2); c.fill();
} else if (tipo === 'flor') {
c.strokeStyle = css(VERDE_); c.beginPath(); c.moveTo(0, s * 0.5); c.lineTo(0, 0); c.stroke();
c.fillStyle = css(ROSA_);
for (let i = 0; i < 5; i++) { const a = i * 1.2566 + k * 0.3; c.beginPath(); c.arc(Math.cos(a) * s * 0.14, Math.sin(a) * s * 0.14, s * 0.1, 0, Math.PI * 2); c.fill(); }
c.fillStyle = css(AMARELO_); c.beginPath(); c.arc(0, 0, s * 0.08, 0, Math.PI * 2); c.fill();
}
c.restore();
}
/** Onde fica cada objeto da receita e como ele se mexe (pular, girar, subir, orbitar, piscar, balançar). */
function objetoReceita(c, o, i, b, x, y, s, k, cor, w) {
const q = Math.max(1, Math.min(5, o.quantidade || 1)), alfa = c.globalAlpha;
for (let j = 0; j < q; j++) {
let ox, oy;
if (o.onde === 'mao_direita') { ox = b.mD.x; oy = b.mD.y; }
else if (o.onde === 'mao_esquerda') { ox = b.mE.x; oy = b.mE.y; }
else if (o.onde === 'maos') { ox = (b.mE.x + b.mD.x) / 2; oy = (b.mE.y + b.mD.y) / 2; }
else if (o.onde === 'chao') { ox = x + s * (0.9 + i * 0.5); oy = y - s * 0.3; }
else if (o.onde === 'acima') { ox = b.cabeca.x + s * (0.8 + j * 0.5); oy = b.cabeca.y - s * 0.6; }
else { const a = (j / q) * Math.PI * 2 + i; ox = b.quadril.x + Math.cos(a) * s * 1.5; oy = b.quadril.y - s * 1.2 + Math.sin(a) * s * 1.1; }
if (q > 1 && (o.onde === 'mao_direita' || o.onde === 'mao_esquerda' || o.onde === 'maos' || o.onde === 'chao')) ox += (j - (q - 1) / 2) * s * 0.4;
let giro = 0;
c.globalAlpha = alfa;
if (o.movimento === 'pular') oy -= Math.abs(Math.sin(k * w * 0.5 + j)) * s * 1.6;
else if (o.movimento === 'girar') giro = k * 4 + j;
else if (o.movimento === 'balancar') ox += Math.sin(k * w + j) * s * 0.3;
else if (o.movimento === 'piscar') c.globalAlpha = alfa * (0.25 + 0.75 * Math.abs(Math.sin(k * 3 + j * 1.7)));
else if (o.movimento === 'subir') {
const f = (k * 0.35 + j / q) % 1;
ox += f * s * 0.8 + Math.sin(k * 2 + j) * s * 0.12; oy -= f * s * 1.8;
c.globalAlpha = alfa * Math.min(1, f / 0.12) * (1 - f);
} else if (o.movimento === 'orbitar') {
const a = k * 1.6 + (j / q) * Math.PI * 2;
ox = b.quadril.x + Math.cos(a) * s * 1.6; oy = b.quadril.y - s * 1.3 + Math.sin(a) * s * 0.9;
}
desenharObjeto(c, o.tipo, ox, oy, s, k, cor, giro, j);
}
c.globalAlpha = alfa;
}
const CENAS = {
// Atividade criada por IA: uma receita (criacao.py) montada com as peças que já existem aqui:
// o bonequinho, poses de braços/pernas e os objetos das outras cenas.
receita(c, x, y, s, k, cor) {
const R = ativ.receita || {};
const w = Math.PI * 2 * (R.ritmo || 0.8), onda = Math.sin(k * w);
const f1 = (onda + 1) / 2, f2 = R.alternados ? 1 - f1 : f1;
const rad = (g) => (g * Math.PI) / 180;
const ang = (b, f) => rad((b ? b.de : 20) + ((b ? b.ate : 40) - (b ? b.de : 20)) * f);
const pulo = (R.pulo || 0) * Math.max(0, onda), sobe = pulo * s * 0.5;
const balanco = Math.sin(k * w * 0.5) * (R.balanco || 0);
const cot = (R.cotovelos || 0) * 1.4;
let pE = [-0.08, 0], pD = [0.08, 0];
if (R.pernas === 'marchando') { pE = [-0.08 - 0.25 * f1, 0.45 * f1]; pD = [0.08 + 0.25 * (1 - f1), -0.45 * (1 - f1)]; }
else if (R.pernas === 'chutando') { const chute = Math.pow(Math.max(0, onda), 2); pD = [0.08 + chute * 0.9, -chute * 0.6]; }
else if (R.pernas === 'dancando') { pE = [-0.1 - f1 * 0.25, f1 * 0.4]; pD = [0.1 + (1 - f1) * 0.25, -(1 - f1) * 0.4]; }
else if (R.pernas === 'pulando') { pE = [-0.06, pulo * 0.3]; pD = [0.06, -pulo * 0.3]; }
const sentado = R.postura === 'sentado';
const b = boneco(c, x + balanco * s * 0.3, y - sobe, s, Object.assign(sentado ? Object.assign({}, SENTADO) : { pE, pD }, {
inclina: balanco * 0.25, cabeca: -balanco * 0.3, fechados: R.forma === 'dormir',
olhoY: R.olhar === 'cima' ? -1 : R.olhar === 'baixo' ? 0.8 : 0, olhoX: R.olhar === 'lado' ? 1 : 0,
bE: [-ang(R.braco_esquerdo, f1), cot], bD: [ang(R.braco_direito, f2), -cot],
}), cor, k);
(R.objetos || []).forEach((o, i) => objetoReceita(c, o, i, b, x, y, s, k, cor, w));
},
ler(c, x, y, s, k, cor) {
const b = boneco(c, x, y, s, {
sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35], cabeca: -0.08, olhoY: 0.8,
olhoX: -0.8 + 1.6 * ((k % 2.4) / 2.4), bE: [0.2, 2.4], bD: [-0.2, -2.4],
}, cor, k);
// livro aberto entre as mãos, com uma página virando de vez em quando
const cx = (b.mE.x + b.mD.x) / 2, cy = (b.mE.y + b.mD.y) / 2 + s * 0.14, lw = s * 0.72, lh = s * 0.55;
c.fillStyle = claro(cor, 0.95); c.strokeStyle = css(cor);
for (const lado of [-1, 1]) {
c.beginPath(); c.moveTo(cx, cy + lh * 0.1); c.lineTo(cx + lado * lw, cy); c.lineTo(cx + lado * lw, cy - lh);
c.lineTo(cx, cy - lh * 0.9); c.closePath(); c.fill(); c.stroke();
}
c.strokeStyle = css(cor, 0.5); c.lineWidth = Math.max(1, s * 0.03);
for (let i = 1; i <= 3; i++) for (const lado of [-1, 1]) {
const yy = cy - lh * (0.25 * i); c.beginPath(); c.moveTo(cx + lado * lw * 0.15, yy); c.lineTo(cx + lado * lw * 0.85, yy - lh * 0.03); c.stroke();
}
const virar = (k % 6) / 0.7; // vira em 0,7 s a cada 6 s
if (virar < 1) {
const px = cx + lw * Math.cos(virar * Math.PI), sobe = Math.sin(virar * Math.PI) * lh * 0.25;
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.moveTo(cx, cy + lh * 0.1); c.lineTo(px, cy - sobe); c.lineTo(px, cy - lh - sobe);
c.lineTo(cx, cy - lh * 0.9); c.closePath(); c.fill(); c.stroke();
}
},
cantar(c, x, y, s, k, cor) {
const balanco = Math.sin(k * 2.2);
const b = boneco(c, x, y, s, {
dx: balanco * 0.12, inclina: balanco * 0.08, cabeca: balanco * 0.1, olhoY: -0.3,
pE: [-0.1 - Math.max(0, balanco) * 0.15, 0], pD: [0.1 - Math.min(0, balanco) * 0.15, 0],
bD: [2.45, 0.9], bE: [-0.9 - 0.4 * Math.sin(k * 4.4), -0.4],
}, cor, k);
// microfone na mão direita, perto da boca
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); capsula(c, b.mD.x, b.mD.y, b.mD.x - s * 0.1, b.mD.y - s * 0.4, s * 0.07); c.fill(); c.stroke();
c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(b.mD.x - s * 0.12, b.mD.y - s * 0.5, s * 0.14, 0, Math.PI * 2); c.fill(); c.stroke();
// notas subindo
c.fillStyle = css(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
const alfa = c.globalAlpha;
for (let i = 0; i < 3; i++) {
const f = (k * 0.45 + i / 3) % 1;
c.globalAlpha = alfa * Math.min(1, f / 0.15) * (1 - f);
nota(c, b.cabeca.x + s * (0.9 + 0.5 * i) + Math.sin(f * 6 + i) * s * 0.2, b.cabeca.y + s * 0.4 - f * s * 1.6, s * 0.75);
}
c.globalAlpha = alfa;
},
bola(c, x, y, s, k, cor) {
const bl = bola(k), chute = Math.max(0, 1 - bl.p / 0.18) + Math.max(0, (bl.p - 0.9) / 0.1);
const b = boneco(c, x, y, s, {
dx: bl.x * 0.5, olhoX: bl.x * 2, olhoY: 0.6 - bl.h * 1.4,
pD: [0.08 + chute * 0.9, -chute * 0.6], pE: [-0.08, 0], bE: [-0.6, 0.3], bD: [0.6, -0.3],
}, cor, k);
const bx = b.quadril.x + s * 0.55 + bl.x * s * 0.3, by = y - s * 0.55 - bl.h * s * 2.6, r = s * 0.3;
const alfa = c.globalAlpha;
c.save(); c.globalAlpha = alfa * 0.35 * (1 - bl.h); c.fillStyle = css(cor); // sombra no chão
c.beginPath(); c.ellipse(bx, y, r * (1.1 - bl.h * 0.5), r * 0.18, 0, 0, Math.PI * 2); c.fill(); c.restore();
c.save(); c.translate(bx, by); c.rotate(k * 5);
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3)); // gomo da bola
c.beginPath(); for (let i = 0; i < 5; i++) { const a = i * 1.2566; c.lineTo(Math.cos(a) * r * 0.38, Math.sin(a) * r * 0.38); } c.closePath(); c.fill();
c.restore();
},
videogame(c, x, y, s, k, cor) {
const aperta = Math.sin(k * 23) * 0.05;
const b = boneco(c, x - s * 1.3, y, s, {
sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35], olhoX: 1, olhoY: 0.1, cabeca: -0.05,
bE: [0.15 + aperta, 0.95], bD: [-0.15 - aperta, -0.95],
}, cor, k);
// controle
const cx = (b.mE.x + b.mD.x) / 2, cy = (b.mE.y + b.mD.y) / 2;
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
caixa(c, cx - s * 0.42, cy - s * 0.16, s * 0.84, s * 0.34, s * 0.16); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3));
c.fillRect(cx - s * 0.3, cy - s * 0.02, s * 0.16, s * 0.05); c.fillRect(cx - s * 0.245, cy - s * 0.08, s * 0.05, s * 0.17);
for (const [dx, dy] of [[0.22, -0.05], [0.3, 0.04]]) { c.beginPath(); c.arc(cx + dx * s, cy + dy * s, s * 0.04, 0, Math.PI * 2); c.fill(); }
// TV com um joguinho: bonequinho pulando obstáculos
const tx = x + s * 1.15, tw = s * 2.1, th = s * 1.45, ty = y - s * 0.55 - th;
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(tx + tw / 2, ty + th); c.lineTo(tx + tw / 2, y); c.moveTo(tx + tw * 0.3, y); c.lineTo(tx + tw * 0.7, y); c.stroke();
caixa(c, tx, ty, tw, th, s * 0.12); c.fill(); c.stroke();
c.save(); caixa(c, tx + s * 0.1, ty + s * 0.1, tw - s * 0.2, th - s * 0.2, s * 0.06); c.clip();
const chao = ty + th - s * 0.32, px = s * 0.11;
c.fillStyle = css(cor, 0.6); c.fillRect(tx, chao, tw, s * 0.05);
const obst = tx + tw - ((k * 1.4) % 1) * (tw + s * 0.4);
c.fillStyle = css(cor); c.fillRect(obst, chao - px * 2, px * 1.4, px * 2);
const ox = tx + tw * 0.3, perto = (ox + px * 2 - obst) / (s * 0.9) + 0.5;
const pulo = perto > 0 && perto < 1 ? Math.sin(perto * Math.PI) : 0;
c.fillStyle = claro(cor, 1); c.fillRect(ox, chao - px * 2.4 - pulo * s * 0.45, px * 1.6, px * 2.4);
if (k % 9 > 7.6) { // vitória: estrelinhas na tela
c.fillStyle = claro(cor, 1); c.font = Math.round(s * 0.4) + 'px sans-serif'; c.textAlign = 'center';
c.fillText('★ ★ ★', tx + tw / 2, ty + th * 0.4);
}
c.restore();
},
pintar(c, x, y, s, k, cor) {
const pc = pincel(k);
const ex = x + s * 0.9, ew = s * 1.6, eh = s * 1.35, ey = y - s * 1.0 - eh;
// cavalete
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(ex + ew * 0.2, y); c.lineTo(ex + ew * 0.5, ey - s * 0.15); c.lineTo(ex + ew * 0.8, y); c.stroke();
c.fillStyle = claro(cor, 0.95); caixa(c, ex, ey, ew, eh, s * 0.05); c.fill(); c.stroke();
// pinceladas que vão aparecendo (sempre as mesmas para a mesma pintura)
const tintas = [cor, [255, 112, 176], [255, 218, 96], [120, 255, 160]];
const n = Math.min(40, Math.floor(k / 0.7));
c.save(); caixa(c, ex, ey, ew, eh, s * 0.05); c.clip();
for (let i = 0; i < n; i++) {
const a = Math.sin(i * 12.9898 + ativ.t0) * 43758.5453, rnd = a - Math.floor(a);
const b2 = Math.sin(i * 78.233 + ativ.t0) * 12345.678, rnd2 = b2 - Math.floor(b2);
c.fillStyle = css(tintas[i % 4], 0.9);
c.beginPath(); c.ellipse(ex + ew * (0.12 + rnd * 0.76), ey + eh * (0.12 + rnd2 * 0.76), s * 0.16, s * 0.07, rnd * 3, 0, Math.PI * 2); c.fill();
}
c.restore();
const b = boneco(c, x - s * 0.5, y, s, {
inclina: -0.06, olhoX: 1, olhoY: pc.y * 0.5, cabeca: -0.08,
bD: [1.75 + pc.y * 0.25, -0.35 + pc.x * 0.3], bE: [-0.7, 0.9], pE: [-0.12, 0], pD: [0.08, 0],
}, cor, k);
// pincel numa mão, paleta na outra
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); c.moveTo(b.mD.x - s * 0.1, b.mD.y + s * 0.08); c.lineTo(b.mD.x + s * 0.4, b.mD.y - s * 0.12); c.stroke();
c.fillStyle = css(tintas[Math.floor(k / 0.7) % 4]); c.beginPath(); c.arc(b.mD.x + s * 0.43, b.mD.y - s * 0.13, s * 0.07, 0, Math.PI * 2); c.fill();
c.fillStyle = claro(cor, 0.95); c.beginPath(); c.ellipse(b.mE.x, b.mE.y, s * 0.32, s * 0.2, -0.3, 0, Math.PI * 2); c.fill(); c.stroke();
for (let i = 0; i < 3; i++) { c.fillStyle = css(tintas[i + 1]); c.beginPath(); c.arc(b.mE.x - s * 0.15 + i * s * 0.13, b.mE.y - s * 0.03, s * 0.05, 0, Math.PI * 2); c.fill(); }
},
malhar(c, x, y, s, k, cor) {
const f = suave(forca(k));
const b = boneco(c, x, y, s, {
olhoY: -0.4 + (1 - f) * 0.5, pE: [-0.22, 0.08], pD: [0.22, -0.08],
bE: [-1.25 - f * 1.75, -1.9 + f * 1.75], bD: [1.25 + f * 1.75, 1.9 - f * 1.75],
}, cor, k);
// halteres
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
for (const m of [b.mE, b.mD]) {
c.fillRect(m.x - s * 0.25, m.y - s * 0.035, s * 0.5, s * 0.07);
for (const lado of [-1, 1]) { caixa(c, m.x + lado * s * 0.28 - s * 0.07, m.y - s * 0.17, s * 0.14, s * 0.34, s * 0.04); c.fill(); c.stroke(); }
}
if (f > 0.85) { // gotinha de suor
const g = (k * 2) % 1;
c.fillStyle = claro(cor, 1 - g);
c.beginPath(); c.arc(b.cabeca.x + s * 0.72, b.cabeca.y + s * 0.3 + g * s * 0.5, s * 0.07, 0, Math.PI * 2); c.fill();
}
},
dormir(c, x, y, s, k, cor) {
// cama com cabeceira, travesseiro e cobertor que sobe e desce com a respiração
const bx0 = x - s * 1.7, bx1 = x + s * 1.9, topo = y - s * 0.75;
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(bx0 + s * 0.12, y); c.lineTo(bx0 + s * 0.12, topo); c.moveTo(bx1 - s * 0.12, y); c.lineTo(bx1 - s * 0.12, topo); c.stroke();
caixa(c, bx0 - s * 0.08, topo - s * 1.25, s * 0.24, s * 1.3, s * 0.1); c.fill(); c.stroke();
caixa(c, bx0, topo - s * 0.3, bx1 - bx0, s * 0.32, s * 0.12); c.fill(); c.stroke();
c.fillStyle = claro(cor, 0.9);
c.beginPath(); c.ellipse(bx0 + s * 0.85, topo - s * 0.5, s * 0.6, s * 0.24, 0, 0, Math.PI * 2); c.fill(); c.stroke();
// bonequinho deitado: desenhado em pé e girado 90°, pés à direita, cabeça no travesseiro
const e = 0.72, linha = topo - s * 0.3 - s * e * 0.5;
c.save(); c.translate(x + s * 1.5, linha); c.rotate(-Math.PI / 2);
boneco(c, 0, 0, s * e, { fechados: true, bE: [-0.1, 0], bD: [0.1, 0] }, cor, k);
c.restore();
const resp = Math.sin(k * 1.4) * s * 0.04;
c.fillStyle = css(cor.map((v) => v * 0.45)); c.strokeStyle = css(cor);
caixa(c, x - s * 0.3, linha - s * 0.42 - resp, s * 2.15, topo - s * 0.3 - (linha - s * 0.42 - resp) + s * 0.06, s * 0.18); c.fill(); c.stroke();
c.strokeStyle = css(cor, 0.5); c.beginPath(); c.moveTo(x - s * 0.1, linha - s * 0.2 - resp); c.lineTo(x + s * 1.7, linha - s * 0.2 - resp); c.stroke();
zzz(c, x - s * 0.9, linha - s * 0.9, s, k, cor);
},
cafe(c, x, y, s, k, cor) {
const f = (k % 5) / 5, gole = suave(faixaDe(f, 0.5, 0.64)) * (1 - suave(faixaDe(f, 0.84, 0.97)));
const b = boneco(c, x, y, s, Object.assign({}, SENTADO, {
olhoX: 0.2, olhoY: 0.3 - gole * 0.8, fechados: gole > 0.85, cabeca: -0.04,
bE: [0.15, 0.95], bD: [-0.15 + gole * 1.75, -0.95 - gole * 3.05],
}), cor, k);
xicara(c, b.mD.x, b.mD.y, s, cor);
if (gole < 0.3) vapor(c, b.mD.x, b.mD.y - s * 0.3, s, k, cor);
},
cozinhar(c, x, y, s, k, cor) {
const ciclo = (k % 2.6) / 2.6, voo = ciclo < 0.55 ? ciclo / 0.55 : -1, h = voo >= 0 ? 4 * voo * (1 - voo) : 0;
const fx = x + s * 1.0, fw = s * 1.7, fh = s * 1.2;
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
caixa(c, fx, y - fh, fw, fh, s * 0.08); c.fill(); c.stroke();
caixa(c, fx + fw * 0.15, y - fh * 0.72, fw * 0.7, fh * 0.5, s * 0.06); c.stroke();
for (let i = 0; i < 3; i++) chama(c, fx + fw * (0.3 + 0.2 * i), y - fh, s * (0.22 + 0.07 * Math.sin(k * 17 + i * 2)), s);
const b = boneco(c, x - s * 0.45, y, s, {
olhoX: 1, olhoY: 0.3 - h, cabeca: -0.05, bE: [-0.4, 0.3],
bD: [1.2 - (voo >= 0 && voo < 0.15 ? 0.25 : 0), 0.35],
}, cor, k);
const px = b.mD.x + s * 0.62, py = b.mD.y + s * 0.02;
c.strokeStyle = css(cor); c.lineWidth = Math.max(2, s * 0.09);
c.beginPath(); c.moveTo(b.mD.x, b.mD.y); c.lineTo(px - s * 0.42, py); c.stroke();
c.fillStyle = escuro(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); c.ellipse(px, py, s * 0.46, s * 0.13, 0, 0, Math.PI * 2); c.fill(); c.stroke();
c.save(); c.translate(px, py - s * 0.1 - h * s * 1.6); c.rotate(voo >= 0 ? voo * Math.PI * 2 : 0);
c.fillStyle = css(AMARELO_); c.beginPath(); c.ellipse(0, 0, s * 0.32, s * 0.08, 0, 0, Math.PI * 2); c.fill(); c.stroke();
c.restore();
},
regar(c, x, y, s, k, cor) {
const cresce = Math.min(1, k / 18), px = x + s * 2.0;
// vaso
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(px - s * 0.42, y - s * 0.6); c.lineTo(px + s * 0.42, y - s * 0.6); c.lineTo(px + s * 0.3, y); c.lineTo(px - s * 0.3, y); c.closePath(); c.fill(); c.stroke();
// planta crescendo: caule, folhas e, no fim, a flor
const alt = s * (0.3 + 1.25 * cresce), base = y - s * 0.6, balanca = Math.sin(k * 1.5) * s * 0.06;
c.strokeStyle = css(VERDE_); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(px, base); c.quadraticCurveTo(px + balanca, base - alt / 2, px + balanca * 2, base - alt); c.stroke();
c.fillStyle = css(VERDE_);
for (let i = 1; i <= 3; i++) {
if (cresce < i * 0.22) continue;
const yy = base - alt * i * 0.25, lado = i % 2 ? 1 : -1;
c.beginPath(); c.ellipse(px + balanca * i * 0.5 + lado * s * 0.18, yy, s * 0.2, s * 0.08, lado * -0.5, 0, Math.PI * 2); c.fill();
}
if (cresce > 0.75) {
const f = (cresce - 0.75) / 0.25, fx = px + balanca * 2, fy = base - alt;
c.fillStyle = css(ROSA_);
for (let i = 0; i < 5; i++) { const a = i * 1.2566 + k * 0.3; c.beginPath(); c.arc(fx + Math.cos(a) * s * 0.16 * f, fy + Math.sin(a) * s * 0.16 * f, s * 0.11 * f, 0, Math.PI * 2); c.fill(); }
c.fillStyle = css(AMARELO_); c.beginPath(); c.arc(fx, fy, s * 0.09 * f, 0, Math.PI * 2); c.fill();
}
const b = boneco(c, x - s * 0.7, y, s, { olhoX: 1, olhoY: 0.8, cabeca: -0.1, inclina: -0.06, bD: [1.35, 0.35], bE: [-0.3, 0.2] }, cor, k);
// regador inclinado e as gotas caindo na planta
const ang = 0.38, rx = b.mD.x + s * 0.05, ry = b.mD.y;
c.save(); c.translate(rx, ry); c.rotate(ang);
c.fillStyle = claro(cor, 0.95); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
caixa(c, 0, -s * 0.22, s * 0.6, s * 0.42, s * 0.08); c.fill(); c.stroke();
c.beginPath(); c.moveTo(s * 0.55, -s * 0.05); c.lineTo(s * 1.0, -s * 0.32); c.stroke();
c.restore();
const ex = rx + Math.cos(ang) * s * 1.0 + Math.sin(ang) * s * 0.32, ey = ry + Math.sin(ang) * s * 1.0 - Math.cos(ang) * s * 0.32;
c.fillStyle = claro(cor, 1);
for (let i = 0; i < 6; i++) {
const f = (k * 1.6 + i / 6) % 1;
c.beginPath(); c.ellipse(ex + (px - ex) * f * 0.8, ey + (base - alt - ey + s * 0.2) * f * f, s * 0.035, s * 0.07, 0, 0, Math.PI * 2); c.fill();
}
},
violao(c, x, y, s, k, cor) {
const toque = Math.sin(k * 11);
const b = boneco(c, x, y, s, Object.assign({}, SENTADO, {
olhoX: -0.4, olhoY: 0.7, cabeca: 0.08 * Math.sin(k * 1.1),
bE: [-1.1, -0.9], bD: [-0.2 + toque * 0.12, -1.0],
}), cor, k);
// violão atravessado no colo: corpo perto da mão que toca, braço até a outra mão
const gx = b.quadril.x + s * 0.3, gy = b.quadril.y - s * 0.35, nx = b.mE.x, ny = b.mE.y;
const a = Math.atan2(ny - gy, nx - gx);
c.save(); c.translate(gx, gy); c.rotate(a);
const L = Math.hypot(nx - gx, ny - gy);
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
caixa(c, s * 0.2, -s * 0.08, L - s * 0.1, s * 0.16, s * 0.04); c.fill(); c.stroke();
caixa(c, L - s * 0.05, -s * 0.12, s * 0.3, s * 0.24, s * 0.06); c.fill(); c.stroke();
c.fillStyle = claro(cor, 0.95);
c.beginPath(); c.arc(-s * 0.28, 0, s * 0.42, 0, Math.PI * 2); c.fill(); c.stroke();
c.beginPath(); c.arc(s * 0.22, 0, s * 0.32, 0, Math.PI * 2); c.fill(); c.stroke();
c.fillStyle = escuro(cor); c.beginPath(); c.arc(s * 0.05, 0, s * 0.11, 0, Math.PI * 2); c.fill();
c.strokeStyle = css(cor, 0.7); c.lineWidth = Math.max(1, s * 0.015);
for (const d of [-0.04, 0, 0.04]) { c.beginPath(); c.moveTo(-s * 0.5, d * s); c.lineTo(L, d * s); c.stroke(); }
c.restore();
c.fillStyle = css(cor); c.beginPath(); c.arc(b.mD.x, b.mD.y, s * 0.13, 0, Math.PI * 2); c.fill();
notas(c, b.cabeca.x - s * 1.6, b.cabeca.y + s * 0.2, s, k, cor, -1);
},
dancar(c, x, y, s, k, cor) {
const u = (Math.sin(k * 5) + 1) / 2, mexe = Math.sin(k * 5) * 0.18;
// globo espelhado e luzinhas no chão
const gx = x, gy = y - s * 4.5;
c.strokeStyle = css(cor, 0.6); c.lineWidth = Math.max(1, s * 0.03);
c.beginPath(); c.moveTo(gx, gy - s * 0.9); c.lineTo(gx, gy - s * 0.35); c.stroke();
c.fillStyle = claro(cor, 0.9); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.arc(gx, gy, s * 0.35, 0, Math.PI * 2); c.fill(); c.stroke();
c.strokeStyle = css(cor, 0.5);
for (let i = -1; i <= 1; i++) { c.beginPath(); c.ellipse(gx, gy, s * 0.35 * Math.abs(Math.cos(k + i)), s * 0.35, 0, 0, Math.PI * 2); c.stroke(); }
const cores = [ROSA_, AMARELO_, VERDE_, cor];
for (let i = 0; i < 6; i++) {
const lx = x + Math.sin(k * 1.3 + i * 1.7) * s * 2.4, alfa = c.globalAlpha;
c.globalAlpha = alfa * 0.35; c.fillStyle = css(cores[i % 4]);
c.beginPath(); c.ellipse(lx, y, s * 0.35, s * 0.07, 0, 0, Math.PI * 2); c.fill();
c.globalAlpha = alfa;
}
boneco(c, x, y, s, {
dx: mexe, inclina: mexe * 0.5, cabeca: -mexe * 0.6, olhoY: -0.2, olhoX: mexe * 3,
bE: [-0.7 - 2.1 * u, 0.5], bD: [0.7 + 2.1 * (1 - u), -0.5],
pE: [-0.1 - u * 0.25, u * 0.4], pD: [0.1 + (1 - u) * 0.25, -(1 - u) * 0.4],
}, cor, k);
for (let i = 0; i < 4; i++) {
const f = (k * 0.7 + i / 4) % 1, alfa = c.globalAlpha;
c.globalAlpha = alfa * Math.sin(f * Math.PI); c.fillStyle = css(cores[i]);
estrela(c, x + Math.cos(i * 1.9) * s * 1.6, y - s * (2 + i * 0.5) - f * s * 0.4, s * 0.12); c.globalAlpha = alfa;
}
},
corda(c, x, y, s, k, cor) {
const th = k * Math.PI * 2 * 0.85, pulo = Math.pow(Math.max(0, Math.cos(th)), 0.6), sobe = pulo * s * 0.45;
c.save(); c.globalAlpha *= 0.3 * (1 - pulo * 0.5); c.fillStyle = css(cor);
c.beginPath(); c.ellipse(x, y, s * 0.6 * (1 - pulo * 0.3), s * 0.08, 0, 0, Math.PI * 2); c.fill(); c.restore();
const b = boneco(c, x, y - sobe, s, {
olhoY: -0.2, bE: [-0.75 + 0.15 * Math.sin(th), 0.6], bD: [0.75 - 0.15 * Math.sin(th), -0.6],
pE: [-0.06, pulo * 0.3], pD: [0.06, -pulo * 0.3],
}, cor, k);
// a corda passa por baixo dos pés quando ele está no alto do pulo
const my = (b.mE.y + b.mD.y) / 2, embaixo = 2 * (y + s * 0.05) - my, emcima = 2 * (b.cabeca.y - s * 1.0) - my;
const cy = (embaixo + emcima) / 2 + Math.cos(th) * (embaixo - emcima) / 2;
c.strokeStyle = claro(cor, 1); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.moveTo(b.mE.x, b.mE.y); c.quadraticCurveTo(x, cy, b.mD.x, b.mD.y); c.stroke();
},
meditar(c, x, y, s, k, cor) {
const flutua = (Math.sin(k * 1.2) + 1) / 2 * s * 0.35, cy = y - s * 1.7 - flutua;
const alfa = c.globalAlpha;
for (let i = 0; i < 3; i++) { // aura
const f = (k * 0.35 + i / 3) % 1;
c.globalAlpha = alfa * 0.35 * (1 - f); c.strokeStyle = css(cor); c.lineWidth = Math.max(1, s * 0.04);
c.beginPath(); c.ellipse(x, cy, s * (1.2 + f * 1.3), s * (1.4 + f * 1.3), 0, 0, Math.PI * 2); c.stroke();
}
c.globalAlpha = alfa * 0.3; c.fillStyle = css(cor);
c.beginPath(); c.ellipse(x, y, s * 0.8 * (1 - flutua / s), s * 0.09, 0, 0, Math.PI * 2); c.fill();
c.globalAlpha = alfa;
boneco(c, x, y - s * 0.3 - flutua, s, {
sentado: true, fechados: true, pE: [-1.45, 2.75], pD: [1.45, -2.75], bE: [-0.75, 0.25], bD: [0.75, -0.25],
}, cor, k);
},
telescopio(c, x, y, s, k, cor) {
// céu estrelado com uma estrela cadente de vez em quando
const alfa = c.globalAlpha;
for (let i = 0; i < 16; i++) {
c.globalAlpha = alfa * (0.35 + 0.65 * Math.abs(Math.sin(k * (1 + rnd(i, 3)) + i)));
c.fillStyle = i % 5 ? claro(cor, 1) : css(AMARELO_);
estrela(c, x + s * (0.2 + rnd(i, 1) * 3.4), y - s * (3.4 + rnd(i, 2) * 1.5), s * (0.05 + 0.05 * rnd(i, 4)));
}
const f = (k % 6) / 1.1;
if (f < 1) {
const sx = x + s * (3.6 - f * 2.2), sy = y - s * (4.9 - f * 0.8);
const g = c.createLinearGradient(sx, sy, sx + s * 0.9, sy - s * 0.33);
g.addColorStop(0, claro(cor, 1)); g.addColorStop(1, css(cor, 0));
c.globalAlpha = alfa * (1 - f); c.strokeStyle = g; c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.moveTo(sx, sy); c.lineTo(sx + s * 0.9, sy - s * 0.33); c.stroke();
}
c.globalAlpha = alfa;
const b = boneco(c, x - s * 0.7, y, s, { olhoX: 1, olhoY: -1, cabeca: 0.15, bD: [2.1, 0.5], bE: [-0.3, 0.2] }, cor, k);
const ox = b.cabeca.x + s * 0.78, oy = b.cabeca.y + s * 0.3, tx = ox + s * 1.6, ty = oy - s * 0.95;
const mx = (ox + tx) / 2, my = (oy + ty) / 2;
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); c.moveTo(mx, my); c.lineTo(mx - s * 0.5, y); c.moveTo(mx, my); c.lineTo(mx + s * 0.5, y); c.moveTo(mx, my); c.lineTo(mx, y); c.stroke();
c.fillStyle = escuro(cor);
c.beginPath(); capsula(c, ox, oy, tx, ty, s * 0.15); c.fill(); c.stroke();
c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(tx, ty, s * 0.17, 0, Math.PI * 2); c.fill(); c.stroke();
},
pipa(c, x, y, s, k, cor) {
const kx = x + s * (2.3 + 0.45 * Math.sin(k * 0.8)), ky = y - s * (4.2 + 0.35 * Math.cos(k * 1.1)), ang = 0.25 * Math.sin(k * 1.6);
const b = boneco(c, x - s * 0.9, y, s, { olhoX: 1, olhoY: -1, cabeca: 0.12, bD: [2.55, 0.35], bE: [-0.4, 0.3] }, cor, k);
c.strokeStyle = css(cor, 0.8); c.lineWidth = Math.max(1, s * 0.025);
c.beginPath(); c.moveTo(b.mD.x, b.mD.y);
c.quadraticCurveTo((b.mD.x + kx) / 2 + s * 0.4, (b.mD.y + ky) / 2 + s * 0.7, kx, ky + s * 0.45); c.stroke();
// rabiola com lacinhos
c.save(); c.translate(kx, ky); c.rotate(ang);
c.strokeStyle = css(cor, 0.8); c.beginPath(); c.moveTo(0, s * 0.5);
for (let i = 1; i <= 6; i++) c.lineTo(Math.sin(k * 3 + i) * s * 0.18, s * (0.5 + i * 0.22));
c.stroke();
for (let i = 2; i <= 6; i += 2) {
const lx = Math.sin(k * 3 + i) * s * 0.18, ly = s * (0.5 + i * 0.22);
c.fillStyle = css(i % 4 ? ROSA_ : AMARELO_);
c.beginPath(); c.moveTo(lx, ly); c.lineTo(lx - s * 0.1, ly - s * 0.06); c.lineTo(lx - s * 0.1, ly + s * 0.06); c.closePath(); c.fill();
c.beginPath(); c.moveTo(lx, ly); c.lineTo(lx + s * 0.1, ly - s * 0.06); c.lineTo(lx + s * 0.1, ly + s * 0.06); c.closePath(); c.fill();
}
const pts = [[0, -0.5], [0.38, 0], [0, 0.5], [-0.38, 0]];
const cores = [ROSA_, AMARELO_, cor, VERDE_];
for (let i = 0; i < 4; i++) {
const [ax, ay] = pts[i], [bx, by] = pts[(i + 1) % 4];
c.fillStyle = css(cores[i]); c.beginPath(); c.moveTo(0, 0); c.lineTo(ax * s, ay * s); c.lineTo(bx * s, by * s); c.closePath(); c.fill();
}
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.04);
c.beginPath(); pts.forEach(([px, py], i) => (i ? c.lineTo(px * s, py * s) : c.moveTo(px * s, py * s))); c.closePath(); c.stroke();
c.restore();
},
bolhas(c, x, y, s, k, cor) {
const b = boneco(c, x - s * 0.6, y, s, { olhoX: 0.8, olhoY: -0.6, bD: [1.6, -4.0], bE: [-0.3, 0.2] }, cor, k);
const wx = b.mD.x + s * 0.35, wy = b.mD.y - s * 0.25;
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.moveTo(b.mD.x, b.mD.y); c.lineTo(wx - s * 0.1, wy + s * 0.08); c.stroke();
c.beginPath(); c.arc(wx, wy, s * 0.12, 0, Math.PI * 2); c.stroke();
const alfa = c.globalAlpha;
for (let i = 0; i < 9; i++) {
const f = (k * 0.22 + i / 9) % 1, r = s * (0.12 + 0.18 * rnd(i, 4));
const bx = wx + f * s * (1.6 + 1.4 * rnd(i, 5)) + Math.sin(k * 2 + i) * s * 0.15, by = wy - f * s * (1.6 + rnd(i, 6));
c.globalAlpha = alfa * Math.min(1, f / 0.08) * (f > 0.9 ? (1 - f) / 0.1 : 1);
c.fillStyle = css(cor, 0.1); c.strokeStyle = css(cor, 0.9); c.lineWidth = Math.max(1, s * 0.03);
c.beginPath(); c.arc(bx, by, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.strokeStyle = claro(cor, 1); c.beginPath(); c.arc(bx, by, r * 0.65, -2.4, -1.6); c.stroke();
}
c.globalAlpha = alfa;
},
skate(c, x, y, s, k, cor) {
const pos = Math.sin(k * 0.9) * 1.3, vel = Math.cos(k * 0.9), ciclo = k % 4;
const ollie = ciclo < 0.7 ? Math.sin(ciclo / 0.7 * Math.PI) : 0, sobe = ollie * s * 0.6;
const bx = x + pos * s, pe = y - s * 0.3 - sobe;
c.save(); c.globalAlpha *= 0.3; c.fillStyle = css(cor);
c.beginPath(); c.ellipse(bx, y, s * 0.9, s * 0.08, 0, 0, Math.PI * 2); c.fill(); c.restore();
c.strokeStyle = css(cor, 0.4); c.lineWidth = Math.max(1, s * 0.03); // risquinhos de velocidade
for (let i = 0; i < 3; i++) { const lx = bx - Math.sign(vel) * s * (1.1 + i * 0.25); c.beginPath(); c.moveTo(lx, pe - s * (0.6 + i * 0.4)); c.lineTo(lx - Math.sign(vel) * s * 0.5 * Math.abs(vel), pe - s * (0.6 + i * 0.4)); c.stroke(); }
boneco(c, bx, pe, s, {
olhoX: vel * 0.8, inclina: -vel * 0.1, bE: [-1.35, 0.25 + 0.2 * Math.sin(k * 3)], bD: [1.35, -0.25 - 0.2 * Math.sin(k * 3)],
pE: [-0.25, 0.35], pD: [0.25, -0.35],
}, cor, k);
c.save(); c.translate(bx, pe); c.rotate(-ollie * 0.25);
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
caixa(c, -s * 0.85, 0, s * 1.7, s * 0.12, s * 0.06); c.fill(); c.stroke();
c.fillStyle = escuro(cor);
for (const lado of [-1, 1]) { c.beginPath(); c.arc(lado * s * 0.55, s * 0.2, s * 0.1, 0, Math.PI * 2); c.fill(); c.stroke(); }
c.restore();
},
computador(c, x, y, s, k, cor) {
const mx0 = x + s * 0.3, mx1 = x + s * 2.7, mt = y - s * 1.45;
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); c.moveTo(mx0 + s * 0.15, mt); c.lineTo(mx0 + s * 0.15, y); c.moveTo(mx1 - s * 0.15, mt); c.lineTo(mx1 - s * 0.15, y); c.stroke();
caixa(c, mx0, mt, mx1 - mx0, s * 0.12, s * 0.04); c.fill(); c.stroke();
const nx = mx0 + s * 0.6, nw = s * 1.4, nh = s * 0.95, ny = mt - s * 0.08 - nh;
caixa(c, nx - s * 0.12, mt - s * 0.08, nw + s * 0.24, s * 0.08, s * 0.03); c.fill(); c.stroke();
caixa(c, nx, ny, nw, nh, s * 0.06); c.fill(); c.stroke();
c.save(); caixa(c, nx + s * 0.06, ny + s * 0.06, nw - s * 0.12, nh - s * 0.12, s * 0.03); c.clip();
const linhas = Math.floor(k * 2.5), visiveis = 6, ini = Math.max(0, linhas - visiveis);
const tintas = [cor, VERDE_, AMARELO_, ROSA_];
for (let i = ini; i < linhas; i++) {
const yy = ny + s * 0.14 + (i - ini) * s * 0.13, recuo = (rnd(i, 7) < 0.4 ? 1 : 0) * s * 0.15;
c.fillStyle = css(tintas[i % 4], 0.85); c.fillRect(nx + s * 0.12 + recuo, yy, (nw - s * 0.4) * (0.3 + 0.6 * rnd(i, 8)), s * 0.05);
}
if (Math.floor(k * 2) % 2) { c.fillStyle = claro(cor, 1); c.fillRect(nx + s * 0.12, ny + s * 0.14 + Math.min(linhas, visiveis) * s * 0.13, s * 0.04, s * 0.08); }
c.restore();
const tecla = Math.sin(k * 20) * 0.05;
boneco(c, x - s * 0.55, y, s, Object.assign({}, SENTADO, {
olhoX: 1, olhoY: 0.2, cabeca: -0.05, bE: [0.9 + tecla, 0.9], bD: [1.35 - tecla, 0.45],
}), cor, k);
},
pescar(c, x, y, s, k, cor) {
const ciclo = k % 8, fisgou = ciclo > 6.2 && ciclo < 7.6, puxa = fisgou ? suave((ciclo - 6.2) / 0.5) : 0;
// água: ondinhas à direita do píer
c.strokeStyle = css(cor, 0.55); c.lineWidth = Math.max(1, s * 0.035);
for (let fila = 0; fila < 3; fila++) {
c.beginPath();
for (let i = 0; i <= 24; i++) {
const wx = x + s * (0.5 + i * 0.14), wy = y - s * 0.05 + fila * s * 0.16 + Math.sin(k * 2 + i * 0.8 + fila) * s * 0.04;
if (i) c.lineTo(wx, wy); else c.moveTo(wx, wy);
}
c.stroke();
}
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
caixa(c, x - s * 2.0, y - s * 0.22, s * 2.5, s * 0.22, s * 0.05); c.fill(); c.stroke();
const b = boneco(c, x - s * 0.8, y - s * 0.22, s, Object.assign({}, SENTADO, {
olhoX: 1, olhoY: 0.6 - puxa * 1.4, bD: [2.0 - puxa * 0.4, -0.9], bE: [0.5, 1.2],
}), cor, k);
const vx = b.mD.x + s * (1.4 - puxa * 0.3), vy = b.mD.y - s * (1.6 + puxa * 0.3);
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); c.moveTo(b.mE.x, b.mE.y); c.lineTo(b.mD.x, b.mD.y); c.lineTo(vx, vy); c.stroke();
const belisca = ciclo > 5.5 && ciclo < 6.2 ? Math.sin(k * 30) * s * 0.06 : 0;
let lx = x + s * 2.6, ly = y - s * 0.05 + Math.sin(k * 2) * s * 0.04 + belisca;
if (fisgou) { // o peixe pula preso na linha
lx = x + s * (2.6 - puxa * 0.6); ly = y - s * (0.2 + Math.sin(puxa * Math.PI) * 1.3 + puxa * 0.3);
c.save(); c.translate(lx, ly + s * 0.25); c.rotate(Math.sin(k * 14) * 0.4);
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.04);
c.beginPath(); c.ellipse(0, 0, s * 0.12, s * 0.28, 0, 0, Math.PI * 2); c.fill(); c.stroke();
c.beginPath(); c.moveTo(0, s * 0.25); c.lineTo(-s * 0.14, s * 0.45); c.lineTo(s * 0.14, s * 0.45); c.closePath(); c.fill(); c.stroke();
c.fillStyle = escuro(cor); c.beginPath(); c.arc(s * 0.04, -s * 0.12, s * 0.03, 0, Math.PI * 2); c.fill();
c.restore();
}
c.strokeStyle = css(cor, 0.85); c.lineWidth = Math.max(1, s * 0.02);
c.beginPath(); c.moveTo(vx, vy); c.quadraticCurveTo((vx + lx) / 2, Math.max(vy, ly) - s * (fisgou ? 0.1 : 0.4), lx, ly); c.stroke();
if (!fisgou) {
c.fillStyle = css(ROSA_); c.beginPath(); c.arc(lx, ly, s * 0.09, Math.PI, 0); c.fill();
c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(lx, ly, s * 0.09, 0, Math.PI); c.fill();
}
},
};
/** Desenha a cena da atividade (camada atrás dos olhos). o = { t, cor }. */
function desenharPalco(o) {
const v = visibilidade(o.t);
if (!v) {
if (palcoTela && palcoTela.style.display !== 'none') palcoTela.style.display = 'none';
return false;
}
if (!palcoTela) {
palcoTela = document.createElement('canvas'); palcoTela.id = 'palcoAtividade';
palcoTela.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:1';
document.body.append(palcoTela); pctx = palcoTela.getContext('2d');
}
palcoTela.style.display = '';
const d = Math.min(1.5, window.devicePixelRatio || 1), W = Math.round(innerWidth * d), H = Math.round(innerHeight * d);
if (palcoTela.width !== W || palcoTela.height !== H) { palcoTela.width = W; palcoTela.height = H; }
const deitado = W >= H * 1.2;
const s = deitado ? H * 0.145 : Math.min(W * 0.075, H * 0.075);
const x = deitado ? W * 0.7 : W / 2, y = deitado ? H * 0.9 : H * 0.95;
const c = pctx, k = o.t - ativ.t0;
c.clearRect(0, 0, W, H);
c.save();
c.globalAlpha = v;
c.translate((1 - v) * (deitado ? W * 0.06 : 0), (1 - v) * (deitado ? 0 : H * 0.08)); // desliza ao entrar
c.strokeStyle = css(o.cor, 0.35); c.lineWidth = Math.max(1, s * 0.04); // chão
c.beginPath(); c.moveTo(x - s * 2.6, y); c.lineTo(Math.min(W * 0.98, x + s * 3.6), y); c.stroke();
c.shadowColor = css(o.cor, 0.55); c.shadowBlur = s * 0.35;
CENAS[ativ.nome](c, x, y, s, k, o.cor);
c.restore();
return true;
}
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = {
@@ -529,5 +1385,10 @@
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, 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),
olharAtividade, palco: desenharPalco,
atividades: Object.fromEntries(Object.entries(ATIVIDADES).map(([k, v]) => [k, { nome: v.nome, emoji: v.emoji }])) };
})();
@@ -0,0 +1,10 @@
package br.carinha.rosto;
import android.app.admin.DeviceAdminReceiver;
/**
* Administrador do aparelho. Vira "dono" (device owner) uma vez, pelo ADB:
* adb shell dpm set-device-owner br.carinha.rosto/.Admin
* A partir daí o app trava a tela nele mesmo (ver Quiosque).
*/
public final class Admin extends DeviceAdminReceiver { }
@@ -85,7 +85,11 @@ public class MainActivity extends Activity {
private static final String PREFS = "carinha";
private static final String CHAVE = "servidor";
private static final String CHAVE_TOKEN = "token";
private static final String PADRAO = "http://192.168.1.15:8100";
private static final String PADRAO = "http://192.168.1.35:8100"; // a Orange Pi
private static final long ESPERA_SERVIDOR = 10000; // ms entre tentativas sem servidor
/** O que a pagina pode guardar no app (sobrevive sem servidor; a copia do APK le). */
private static final java.util.Set<String> LEMBRAVEIS =
new java.util.HashSet<String>(Arrays.asList("aparencia", "config"));
private static final String TAG = "CarinhaMic";
private static final String TAG_JS = "CarinhaJS"; // console.log da pagina
@@ -247,6 +251,7 @@ public class MainActivity extends Activity {
cfg.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
}
web.addJavascriptInterface(new PonteApp(), "CarinhaApp");
web.setWebViewClient(new WebViewClient() {
@Override
public void onReceivedError(WebView vista, WebResourceRequest pedido,
@@ -414,7 +419,8 @@ public class MainActivity extends Activity {
private void menu() {
final String[] opcoes = {"Ver a camera", "Voltar para a carinha",
(movimentoLigado ? "Desligar reacao ao movimento" : "Ligar reacao ao movimento"),
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video", "Token do servidor"};
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video", "Token do servidor",
"Modo quiosque"};
new AlertDialog.Builder(this)
.setTitle("Carinha")
.setItems(opcoes, new DialogInterface.OnClickListener() {
@@ -426,12 +432,55 @@ public class MainActivity extends Activity {
else if (i == 3) pedirServidor();
else if (i == 4) menuSensores();
else if (i == 5) menuVideo();
else pedirToken();
else if (i == 6) pedirToken();
else menuQuiosque();
}
})
.show();
}
/** Manutenção: soltar a tela para usar o Android, ou deixar de ser dono do aparelho. */
private void menuQuiosque() {
if (!Quiosque.dono(this)) {
new AlertDialog.Builder(this)
.setTitle("Modo quiosque desligado")
.setMessage("Para ligar, pelo PC:\nadb shell dpm set-device-owner br.carinha.rosto/.Admin")
.setPositiveButton("OK", null)
.show();
return;
}
final boolean pausado = Quiosque.pausado();
new AlertDialog.Builder(this)
.setTitle(pausado ? "Quiosque pausado" : "Quiosque ligado")
.setItems(new String[]{
pausado ? "Travar de novo" : "Pausar até reiniciar (abre o Android)",
"Desligar o modo quiosque de vez"}, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface d, int i) {
if (i == 0) {
if (pausado) { Quiosque.retomar(MainActivity.this); aviso("quiosque ligado"); }
else { Quiosque.pausar(MainActivity.this); aviso("quiosque pausado: Home e barra liberados até reiniciar"); }
} else confirmarRemoverQuiosque();
}
})
.show();
}
private void confirmarRemoverQuiosque() {
new AlertDialog.Builder(this)
.setTitle("Desligar o modo quiosque?")
.setMessage("A carinha deixa de ser dona do aparelho. Para voltar, será preciso o ADB de novo.")
.setPositiveButton("Desligar", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface d, int w) {
Quiosque.remover(MainActivity.this);
aviso("modo quiosque desligado");
}
})
.setNegativeButton("Cancelar", null)
.show();
}
/** Liga/desliga a reacao ao movimento (guardado nas preferencias). */
private void salvarAjuste(String chave, Object valor) {
try { JSONObject c = new JSONObject(); c.put(chave, valor); remoto.configurar(c);
@@ -500,14 +549,63 @@ public class MainActivity extends Activity {
Toast.makeText(this, texto, Toast.LENGTH_LONG).show();
}
/** Servidor fora do ar: mostra a carinha embutida no APK (sem controle remoto). */
/**
* Servidor fora do ar: a copia do APK mostra a carinha dormindo, com a ultima aparencia
* guardada, e o desenho "placa x celular". Enquanto isso o app tenta o servidor de novo.
*/
private void cairParaLocal(WebView vista) {
if (jaCaiuParaLocal) return;
jaCaiuParaLocal = true;
usandoLocal = true;
naCamera = false;
aviso("sem servidor " + servidor + " — usando o rosto local");
Log.i(TAG, "sem servidor " + servidor + ": rosto local dormindo, tentando de novo");
vista.loadUrl("file:///android_asset/carinha.html");
procurarServidor();
}
/** Sem servidor: a cada 10 s pergunta se ele voltou; voltou, abre o rosto dele de novo. */
private void procurarServidor() {
web.removeCallbacks(tentarServidor);
web.postDelayed(tentarServidor, ESPERA_SERVIDOR);
}
private final Runnable tentarServidor = new Runnable() {
@Override public void run() {
if (!usandoLocal) return;
if (!emPrimeiroPlano) { procurarServidor(); return; }
final String alvo = servidor;
new Thread(new Runnable() {
@Override public void run() {
final boolean voltou = servidorResponde(alvo);
runOnUiThread(new Runnable() {
@Override public void run() {
if (!usandoLocal || !alvo.equals(servidor)) return;
if (!voltou) { procurarServidor(); return; }
Log.i(TAG, "servidor voltou: " + servidor);
usandoLocal = false;
jaCaiuParaLocal = false;
abrirRosto();
}
});
}
}, "procura-servidor").start();
}
};
/** Qualquer resposta HTTP (até 401 de token) quer dizer que o servidor está no ar. */
private static boolean servidorResponde(String endereco) {
java.net.HttpURLConnection c = null;
try {
c = (java.net.HttpURLConnection) new java.net.URL(endereco + "/config").openConnection();
c.setConnectTimeout(2500);
c.setReadTimeout(2500);
c.setUseCaches(false);
return c.getResponseCode() > 0;
} catch (Exception e) {
return false;
} finally {
if (c != null) c.disconnect();
}
}
private void pedirServidor() {
@@ -742,12 +840,14 @@ public class MainActivity extends Activity {
rtcWeb.onResume();
web.evaluateJavascript("window.carinhaAtiva=true", null);
telaCheia();
Quiosque.aplicar(this);
// voltou do segundo plano: atualiza o rosto — mas sem expulsar quem
// estava vendo a pagina da camera
if (jaRecarregou && !naCamera) {
abrirRosto();
}
jaRecarregou = true;
if (usandoLocal) procurarServidor();
pedirPermissoes(); // religa o microfone (onPause desligou)
if (!naCamera) iniciarMovimento(); // e volta a enxergar o movimento
}
@@ -1106,6 +1206,20 @@ public class MainActivity extends Activity {
});
}
/** window.CarinhaApp: guarda a ultima aparencia/ajustes vindos do servidor para a copia do APK. */
private final class PonteApp {
@JavascriptInterface public void guardar(String chave, String json) {
if (!LEMBRAVEIS.contains(chave) || json == null || json.length() > 50000) return;
try { new JSONObject(json); } catch (Exception e) { return; }
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString("ultimo_" + chave, json).apply();
}
@JavascriptInterface public String lembrado(String chave) {
if (!LEMBRAVEIS.contains(chave)) return null;
return getSharedPreferences(PREFS, MODE_PRIVATE).getString("ultimo_" + chave, null);
}
@JavascriptInterface public String servidor() { return servidor; }
}
private final class PonteRTC {
/** Foto do vídeo ao vivo (data:image/jpeg;base64,...) para o histórico. */
@JavascriptInterface public void foto(final String dataUrl) {
@@ -0,0 +1,118 @@
package br.carinha.rosto;
import android.Manifest;
import android.app.Activity;
import android.app.ActivityManager;
import android.app.admin.DevicePolicyManager;
import android.app.admin.SystemUpdatePolicy;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.BatteryManager;
import android.os.Build;
import android.provider.Settings;
import android.util.Log;
/**
* Modo quiosque: com o app como dono do aparelho, a carinha é a tela inicial,
* abre sozinha ao ligar e ninguém sai dela (sem barra de status, sem tela de bloqueio).
* Sem ser dono, tudo aqui é ignorado e o app funciona como antes.
*/
public final class Quiosque {
private static final String TAG = "CarinhaQuiosque";
/** Pausado pelo menu para manutenção; volta sozinho quando o celular reinicia. */
private static boolean pausado;
private Quiosque() { }
private static DevicePolicyManager dpm(Context c) {
return (DevicePolicyManager) c.getSystemService(Context.DEVICE_POLICY_SERVICE);
}
private static ComponentName admin(Context c) { return new ComponentName(c, Admin.class); }
public static boolean dono(Context c) {
DevicePolicyManager d = dpm(c);
return d != null && d.isDeviceOwnerApp(c.getPackageName());
}
public static boolean pausado() { return pausado; }
/** Chamado no onResume: reaplica as travas (idempotente). */
public static void aplicar(Activity a) {
if (pausado || !dono(a)) return;
DevicePolicyManager d = dpm(a);
ComponentName adm = admin(a);
String pacote = a.getPackageName();
try {
d.setLockTaskPackages(adm, new String[]{pacote});
// a carinha é a tela inicial: botão Home e o fim do boot caem nela
IntentFilter inicio = new IntentFilter(Intent.ACTION_MAIN);
inicio.addCategory(Intent.CATEGORY_HOME);
inicio.addCategory(Intent.CATEGORY_DEFAULT);
d.addPersistentPreferredActivity(adm, inicio, new ComponentName(a, MainActivity.class));
// tela ligada enquanto estiver na tomada, USB ou carregador sem fio
d.setGlobalSetting(adm, Settings.Global.STAY_ON_WHILE_PLUGGED_IN, String.valueOf(
BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS));
if (Build.VERSION.SDK_INT >= 23) {
d.setKeyguardDisabled(adm, true);
d.setStatusBarDisabled(adm, true);
// atualização do sistema só de madrugada (3h–5h), nunca no meio do dia
d.setSystemUpdatePolicy(adm, SystemUpdatePolicy.createWindowedInstallPolicy(180, 300));
// câmera e microfone sem perguntar, inclusive depois de reinstalar o app
for (String p : new String[]{Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO})
d.setPermissionGrantState(adm, pacote, p, DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED);
}
// o botão Ligar continua abrindo o menu de desligar/reiniciar
if (Build.VERSION.SDK_INT >= 28)
d.setLockTaskFeatures(adm, DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS);
if (!travado(a)) a.startLockTask();
} catch (Exception e) {
Log.e(TAG, "aplicar: " + e);
}
}
/** Solta a tela até o próximo reinício, para mexer nas configurações do Android. */
public static void pausar(Activity a) {
if (!dono(a)) return;
pausado = true;
DevicePolicyManager d = dpm(a);
ComponentName adm = admin(a);
try {
if (travado(a)) a.stopLockTask();
d.clearPackagePersistentPreferredActivities(adm, a.getPackageName());
if (Build.VERSION.SDK_INT >= 23) {
d.setKeyguardDisabled(adm, false);
d.setStatusBarDisabled(adm, false);
}
} catch (Exception e) {
Log.e(TAG, "pausar: " + e);
}
}
/** Volta a travar sem precisar reiniciar. */
public static void retomar(Activity a) {
pausado = false;
aplicar(a);
}
/** Deixa de ser dono do aparelho de vez (para desinstalar ou trocar de app). */
@SuppressWarnings("deprecation")
public static void remover(Activity a) {
if (!dono(a)) return;
pausar(a);
try {
dpm(a).clearDeviceOwnerApp(a.getPackageName());
} catch (Exception e) {
Log.e(TAG, "remover: " + e);
}
}
private static boolean travado(Activity a) {
ActivityManager am = (ActivityManager) a.getSystemService(Context.ACTIVITY_SERVICE);
if (am == null) return false;
if (Build.VERSION.SDK_INT >= 23) return am.getLockTaskModeState() != ActivityManager.LOCK_TASK_MODE_NONE;
return am.isInLockTaskMode();
}
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Como dono do aparelho, o quiosque não precisa de políticas de administrador antigas -->
<device-admin>
<uses-policies />
</device-admin>
+37
View File
@@ -0,0 +1,37 @@
# Desativa no S10 os apps que não servem ao robô (reversível: .\enxugar-s10.ps1 -Reativar)
param([switch]$Reativar)
$adb = 'C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools/android-sdk/platform-tools/adb.exe'
$pacotes = @(
# Bixby
'com.samsung.android.bixby.agent', 'com.samsung.android.bixby.service', 'com.samsung.android.bixby.wakeup',
'com.samsung.systemui.bixby2', 'com.samsung.android.app.settings.bixby', 'com.samsung.android.visionintelligence',
'com.samsung.android.app.spage', 'com.samsung.android.app.routines',
# enfeites da Samsung
'com.samsung.android.app.tips', 'com.samsung.android.aremoji', 'com.samsung.android.ardrawing', 'com.samsung.android.arzone',
'com.samsung.android.livestickers', 'com.samsung.android.stickercenter', 'com.samsung.android.app.camera.sticker.facearavatar.preload',
'com.samsung.android.app.dressroom', 'com.samsung.android.app.ledbackcover', 'com.samsung.android.app.watchmanagerstub',
'com.samsung.android.themestore', 'com.samsung.android.app.aodservice', 'com.samsung.android.game.gamehome',
'com.samsung.android.game.gametools', 'com.samsung.android.app.reminder', 'com.samsung.android.forest',
'com.sec.android.app.vepreload', 'com.samsung.android.mateagent', 'com.samsung.android.beaconmanager',
'com.samsung.android.kidsinstaller',
# contas e loja Samsung
'com.sec.android.app.samsungapps', 'com.sec.android.app.billing', 'com.samsung.android.scloud',
'com.samsung.android.samsungpass', 'com.samsung.android.samsungpassautofill', 'com.samsung.android.spayfw',
'com.samsung.android.smartswitchassistant', 'com.sec.android.easyMover.Agent', 'com.samsung.android.easysetup',
'com.samsung.android.app.sharelive', 'com.sec.android.app.desktoplauncher', 'com.sec.android.desktopmode.uiservice',
'com.samsung.android.mdx', 'com.samsung.android.mdx.kit', 'com.samsung.android.mdx.quickboard',
# apps de uso pessoal
'com.samsung.android.messaging', 'com.samsung.android.calendar', 'com.google.android.gm', 'com.google.android.youtube',
'com.google.android.apps.maps', 'com.google.android.apps.tachyon', 'com.google.android.googlequicksearchbox',
# pré-instalados
'com.facebook.katana', 'com.facebook.services', 'com.facebook.system', 'com.facebook.appmanager',
'com.microsoft.skydrive', 'com.microsoft.appmanager', 'br.com.bemobi.appsclub.vivo'
)
$acao = if ($Reativar) { 'enable' } else { 'disable-user --user 0' }
$ok = 0; $falhas = @()
foreach ($p in $pacotes) {
$r = & $adb -d shell "pm $acao $p" 2>&1 | Out-String
if ($r -match 'new state') { $ok++ } else { $falhas += "$p -> $($r.Trim())" }
}
"$ok de $($pacotes.Count) " + $(if ($Reativar) { 'reativados' } else { 'desativados' })
$falhas
+469 -52
View File
@@ -5,7 +5,13 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}
svg{display:block;width:100%;height:100%}
svg{display:block;width:100%;height:100%;position:relative;z-index:2}
/* atividade (momentos dele): a tela se divide. Deitado, o rosto vai para a esquerda e o bonequinho
aparece à direita; em pé, o rosto sobe e ele aparece embaixo. */
#tela,#rostoMotor{transition:transform .8s cubic-bezier(.2,.8,.2,1);transform-origin:50% 50%}
body.atividade #tela,body.atividade #rostoMotor{transform:translateX(-23%) scale(.74)}
@media (orientation:portrait){body.atividade #tela,body.atividade #rostoMotor{transform:translateY(-20%) scale(.8)}}
body.atividade #tela>rect{opacity:0} /* o fundo preto do SVG não pode tapar o bonequinho */
.corpo{fill-rule:evenodd}
.traco{fill:none;stroke-linecap:round}
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
@@ -14,6 +20,52 @@
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
z-index:5;pointer-events:none;text-align:center;opacity:.82;font-family:system-ui,sans-serif;
animation:semServidorEntra 1.2s ease-out}
#semServidor svg{display:block;margin:0 auto;overflow:visible;filter:drop-shadow(0 0 3px currentColor)}
#semServidor svg.horizontal{display:none}
#semServidor svg.vertical{height:58vh;width:auto;max-width:100%}
#semServidor svg.vertical .olhinhos{animation-name:semServidorOlharCima}
@media (orientation:portrait){
body.semServidor #tela,body.semServidor #rostoMotor{transform:translateY(-20%) scale(.8)}
#semServidor{right:auto;top:auto;left:50%;bottom:3.5vh;transform:translateX(-50%);width:min(80vw,400px)}
#semServidor svg.horizontal{display:block;width:100%;height:auto}
#semServidor svg.vertical{display:none}
}
#semServidor .titulo{text-wrap:balance;font-weight:600;font-size:clamp(12px,3.2vh,20px);margin-top:.4em;letter-spacing:.02em}
#semServidor .sub{font-size:clamp(10px,2.4vh,15px);opacity:.6;margin-top:.25em;font-variant-numeric:tabular-nums}
#semServidor .placa rect,#semServidor .celular>rect{fill:none;stroke:currentColor;stroke-width:3}
#semServidor .chip{fill:currentColor;fill-opacity:.25}
#semServidor .pino{fill:currentColor;opacity:.7}
#semServidor .led{fill:#ff5a5a;animation:semServidorLed 1.6s steps(1) infinite}
#semServidor .cabo{stroke:currentColor;stroke-width:3;stroke-dasharray:5 6;opacity:.5;fill:none}
#semServidor .arco{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;opacity:0;
animation:semServidorArco 2.4s ease-out infinite}
#semServidor .a2{animation-delay:.3s}
#semServidor .a3{animation-delay:.6s}
#semServidor .xis path{stroke:#ff5a5a;stroke-width:5;stroke-linecap:round;fill:none}
#semServidor .xis{transform-box:fill-box;transform-origin:center;animation:semServidorXis 2.4s ease-in-out infinite}
#semServidor .olhinhos{fill:currentColor;transform-box:fill-box;transform-origin:center;
animation:semServidorOlhar 5s ease-in-out infinite}
#semServidor .boquinha{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round}
#semServidor .celular{transform-box:fill-box;transform-origin:50% 100%;animation:semServidorProcura 4.8s ease-in-out infinite}
#semServidor.cutucado .celular{animation:semServidorChacoalha .5s ease-in-out}
@keyframes semServidorEntra{from{opacity:0}to{opacity:.82}}
/* em pé no canto direito: o celularzinho olha para cima, para a placa */
@keyframes semServidorOlharCima{0%,30%{transform:translateY(-3px) scaleY(1)}34%{transform:translateY(-3px) scaleY(.1)}38%,60%{transform:translateY(-3px) scaleY(1)}
70%,90%{transform:translateY(1px) scaleY(1)}100%{transform:translateY(-3px)}}
@keyframes semServidorArco{0%{opacity:0}25%{opacity:.9}70%,100%{opacity:0}}
@keyframes semServidorXis{0%,55%,100%{transform:scale(1);opacity:.75}65%{transform:scale(1.25);opacity:1}80%{transform:scale(1);opacity:.85}}
@keyframes semServidorLed{0%{opacity:1}50%{opacity:.15}}
/* o celularzinho olha para a placa, olha de volta e pisca */
@keyframes semServidorOlhar{0%,30%{transform:translateX(-3px) scaleY(1)}34%{transform:translateX(-3px) scaleY(.1)}38%,60%{transform:translateX(-3px) scaleY(1)}
70%,90%{transform:translateX(1px) scaleY(1)}100%{transform:translateX(-3px)}}
@keyframes semServidorProcura{0%,100%{transform:rotate(0)}20%{transform:rotate(-5deg)}40%{transform:rotate(0)}}
@keyframes semServidorChacoalha{0%,100%{transform:rotate(0)}25%{transform:rotate(-9deg)}50%{transform:rotate(7deg)}75%{transform:rotate(-4deg)}}
@media(prefers-reduced-motion:reduce){#semServidor *,#semServidor{animation:none!important}#semServidor .arco{opacity:.6}}
</style>
<body>
<svg id="tela" viewBox="0 0 480 320" preserveAspectRatio="xMidYMid meet">
@@ -100,7 +152,7 @@ function cuidarDaTela() {
const agora = performance.now();
if (!r.dormindo) SONO.desde = 0;
else if (!SONO.desde) SONO.desde = agora;
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
const apagar = SONO.apagar && r.dormindo && !window.carinhaSemServidor && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
if (apagar !== SONO.apagada) {
SONO.apagada = apagar;
window.carinhaTelaApagada = apagar;
@@ -203,8 +255,25 @@ function caminhoBoca(tipo) {
return "M " + (cx - meia) + "," + (cy - l * 0.10) +
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
}
return "M " + (cx - l * 0.30) + "," + cy + // repouso: leve
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0";
// repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
const f = FEICAO.atual, w = l * 0.30, mx = cx + f.dx * l * 0.10;
const yE = cy - Math.max(0, f.lado) * l * 0.12, yD = cy - Math.max(0, -f.lado) * l * 0.12;
const curva = l * (0.18 + f.curva * 0.35 - ZANGA * 0.3);
let d = "M " + (mx - w) + "," + yE + " Q " + mx + "," + (cy + curva - f.abre * l * 0.12) + " " + (mx + w) + "," + yD;
if (f.abre > 0.04) d += " Q " + mx + "," + (cy + curva + f.abre * l * 0.30) + " " + (mx - w) + "," + yE;
return d;
}
/* Sobrancelha i (0 = esquerda): humor do gesto + microexpressão + desce um pouco ao piscar. */
function caminhoSobrancelha(i, esc) {
const c = i === 0 ? E1 : E2, lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, f = FEICAO.atual;
const ab = i === 0 ? r.ab : (r.ab2 === null ? r.ab : r.ab2);
const piscando = 1 - Math.min(1, Math.max(0, ab));
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC - (f.sob[i] - piscando * 3) * ESC;
const base = r.forma === 'bravo' ? 18 : r.forma === 'triste' ? -14 : ZANGA * 14; // zanga franze a testa
const inc = (base + f.inc[i]) * lado * ESC;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
return 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele);
}
// ------------------------------------------------------------------ desenho
@@ -256,6 +325,13 @@ function olho(indice, cx, cy, rx, ry, ab, forma, cor, lado, lx, ly) {
if(p.desenho!==desenho){if(p.figurinha)p.figurinha.remove();p.figurinha=window.carinhaConstruirSVG(desenho);p.desenho=desenho;if(p.figurinha)document.getElementById('o'+(indice+1)).append(p.figurinha);}
if(p.figurinha){p.figurinha.style.display='';p.figurinha.setAttribute('x',cx-rx*.65);p.figurinha.setAttribute('y',cy-ry*.65);p.figurinha.setAttribute('width',rx*1.3);p.figurinha.setAttribute('height',ry*1.3);}
}
if (espiralAtiva()) {
p.traco.setAttribute("d", caminhoEspiral(cx, cy, Math.min(rx, ry) * 0.95, lado));
p.traco.setAttribute("stroke-width", rx * 0.17);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
return;
}
if (icone) {
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
@@ -345,7 +421,23 @@ function sequencias() {
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
return { esconder, espiar, acenar, pensar, musica,
// Mais expressões: risada, bocejo, confusão, tontura, timidez e comemoração.
const pula = (n, alto, baixo, d) => Array.from({ length: n }, (_, i) => [{ dy: i % 2 ? baixo : alto }, d]);
const rir = [[{ forma: "feliz", boca: "aberta", esc: 1.08, dy: -4 }, 0.12], ...pula(8, -6, 2, 0.1),
[{ forma: "feliz", boca: "sorriso", dy: 0 }, 0.25], [{ forma: "aberto", boca: "neutra", esc: 1.0 }, 0.3]];
const bocejar = [[{ ab: 0.5, boca: "aberta", dy: -4, esc: 1.05 }, 0.5], [{ ab: 0.25, dy: -5 }, 1.0],
[{ ab: 0.0, boca: "neutra", dy: 2, esc: 1.0 }, 0.25], [{ ab: 1.0, dy: 0 }, 0.35]];
const confuso = [[{ ab2: 0.55, dx: -8, esc: 0.98 }, 0.25], [{ dx: 8 }, 0.45], [{ dx: -6 }, 0.4], [{}, 0.4],
[{ ab2: 1.0, dx: 0, esc: 1.0 }, 0.3]];
const balanca = Array.from({ length: 8 }, (_, i) => [{ dx: i % 2 ? -5 : 5, dy: i % 2 ? 2 : -2 }, 0.2]);
const tonto = [[{ esc: 1.05, boca: "aberta" }, 0.15], ...balanca, [{ dx: 0, dy: 0, esc: 1.0, boca: "neutra" }, 0.35]];
const timido = [[{ forma: "feliz", boca: "sorriso", dx: -14, dy: 9, esc: 0.9, cor: ROSA }, 0.35], [{}, 1.2],
[{ dx: 0, dy: 0, esc: 1.0, forma: "aberto", cor: CIANO }, 0.4]];
const comemorar = [[{ forma: "feliz", boca: "sorriso", cor: AMARELO, dy: -10, esc: 1.15 }, 0.15], [{ dy: 2, cor: ROSA }, 0.15],
[{ dy: -10, cor: VERDE }, 0.15], [{ dy: 2, cor: CIANO }, 0.15], [{ dy: -10, cor: AMARELO }, 0.15],
[{ dy: 0, cor: CIANO }, 0.2], [{ forma: "aberto", esc: 1.0 }, 0.3]];
return { rir, bocejar, confuso, tonto, timido, comemorar,
esconder, espiar, acenar, pensar, musica,
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre };
}
@@ -359,6 +451,85 @@ const r = {
const SEQS = sequencias();
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
/* Modo vida (servidor): o humor do bichinho escolhe os gestos soltos entre um ato e outro. */
const LIVRES_HUMOR = {
feliz: ["feliz", "alegre", "piscadinha", "coracao", "olhar_esq", "olhar_dir"],
tranquilo: ["olhar_esq", "olhar_dir", "feliz", "piscadinha", "olhar_baixo", "timido"],
bravo: ["bravo", "olhar_baixo", "olhar_esq", "olhar_dir", "confuso"],
entediado: ["olhar_esq", "olhar_dir", "olhar_baixo", "pensar", "bocejar", "confuso"],
carente: ["olhar_baixo", "triste", "olhar_esq", "olhar_dir", "timido"],
triste: ["triste", "olhar_baixo", "olhar_baixo"],
cansado: ["olhar_baixo", "piscar2", "bocejar"],
};
let HUMOR = "normal";
/* Modo vida: zanga (0 a 1) deixa o rosto emburrado aos poucos quando a alegria cai; sono profundo =
dormiu de cansaço, e aí som, movimento e câmera não acordam (como na madrugada). */
let ZANGA = 0;
let SONO_PROFUNDO = false;
/* Microexpressões acordado: de vez em quando a boca e as sobrancelhas mexem sozinhas (um canto
da boca sobe, uma sobrancelha arqueia, um suspiro) e voltam devagar ao neutro. O humor do modo
vida escolhe quais. Valores em "pixels de projeto" (multiplicados por ESC no desenho). */
function feicaoNeutra() { return { curva: 0, lado: 0, abre: 0, dx: 0, sob: [0, 0], inc: [0, 0] }; }
const FEICAO = { atual: feicaoNeutra(), alvo: feicaoNeutra(), prox: 0, volta: 0, t: 0 };
const ao = () => (Math.random() < 0.5 ? 1 : -1);
const MICRO = {
arquear: () => { const f = feicaoNeutra(), i = Math.random() < 0.5 ? 0 : 1; f.sob[i] = 12; f.inc[i] = -4; return f; },
interesse: () => Object.assign(feicaoNeutra(), { sob: [8, 8], abre: 0.35 }),
meioSorriso: () => Object.assign(feicaoNeutra(), { lado: ao(), curva: 0.1 }),
sorrisinho: () => Object.assign(feicaoNeutra(), { curva: 0.35, sob: [4, 4] }),
ladinho: () => { const d = ao(); return Object.assign(feicaoNeutra(), { dx: d, lado: d * 0.4, curva: -0.08 }); },
pensativo: () => Object.assign(feicaoNeutra(), { inc: [8, 8], sob: [-3, -3], dx: ao() * 0.7, curva: -0.15 }),
preocupado: () => Object.assign(feicaoNeutra(), { inc: [-9, -9], sob: [4, 4], curva: -0.3 }),
suspiro: () => Object.assign(feicaoNeutra(), { sob: [5, 5], abre: 0.6, curva: -0.05 }),
};
const MICRO_HUMOR = {
feliz: ["sorrisinho", "sorrisinho", "meioSorriso", "arquear", "interesse"],
tranquilo: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse"],
entediado: ["ladinho", "ladinho", "pensativo", "suspiro", "arquear"],
carente: ["preocupado", "preocupado", "suspiro", "meioSorriso"],
triste: ["preocupado", "suspiro", "pensativo"],
cansado: ["suspiro", "suspiro", "ladinho"],
bravo: ["pensativo", "pensativo", "ladinho", "suspiro"],
normal: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse", "pensativo", "suspiro"],
};
function atualizarFeicao(s) {
const dt = Math.min(0.1, Math.max(0, s - (FEICAO.t || s)));
FEICAO.t = s;
// só no rosto "de repouso": gesto com boca própria (sorriso, triste...) ou dormindo = neutro
const livre = !r.dormindo && !congelado && r.boca === "neutra";
if (!livre) {
FEICAO.alvo = feicaoNeutra();
FEICAO.prox = Math.max(FEICAO.prox, s + 1.5);
} else if (s >= FEICAO.prox) {
const lista = MICRO_HUMOR[HUMOR] || MICRO_HUMOR.normal;
FEICAO.alvo = MICRO[lista[Math.floor(Math.random() * lista.length)]]();
FEICAO.volta = s + 0.9 + Math.random() * 1.6;
FEICAO.prox = s + (2.5 + Math.random() * 5) * ({ cansado: 1.6, triste: 1.4, feliz: 0.8 }[HUMOR] || 1);
} else if (s >= FEICAO.volta) FEICAO.alvo = feicaoNeutra();
const k = 1 - Math.exp(-dt * 6), a = FEICAO.atual, b = FEICAO.alvo;
for (const c of ["curva", "lado", "abre", "dx"]) a[c] += (b[c] - a[c]) * k;
for (let i = 0; i < 2; i++) { a.sob[i] += (b.sob[i] - a.sob[i]) * k; a.inc[i] += (b.inc[i] - a.inc[i]) * k; }
}
/* Gestos soltos mais humanos: quase sempre coisas sutis (olhar de lado, piscar devagar, olhar para
baixo); de vez em quando um gesto maior do humor, nunca o mesmo duas vezes seguidas. Gestos
"para alguém" (acenar, tímido, piscadinha...) só quando tem gente por perto. */
const SUTIS = ["olhar_esq", "olhar_dir", "olhar_baixo", "piscar2", "olhar_esq", "olhar_dir"];
const PARA_ALGUEM = ["acenar", "coracao", "piscadinha", "timido", "esconder", "espiar", "surpreso"];
const RECENTES = [];
function alguemOlhando() {
const agora = performance.now() / 1000;
return (MOV.ativo && agora - MOV.ultimoMov < 30) || (TRACK.alvo && agora - TRACK.visto < 30);
}
function gestoSolto() {
const grande = Math.random() < ({ feliz: 0.3, entediado: 0.25, bravo: 0.3 }[HUMOR] || 0.18);
let lista = grande ? (LIVRES_HUMOR[HUMOR] || LIVRES) : SUTIS;
if (!alguemOlhando()) lista = lista.filter((g) => !PARA_ALGUEM.includes(g));
const opcoes = lista.filter((g) => !RECENTES.includes(g));
const g = (opcoes.length ? opcoes : SUTIS)[Math.floor(Math.random() * (opcoes.length || SUTIS.length))];
RECENTES.push(g); if (RECENTES.length > 2) RECENTES.shift();
return g;
}
function proximo() {
if (!r.fila.length) { r.kf = null; return; }
@@ -373,7 +544,22 @@ function proximo() {
r.kf_t = 0;
}
const ESPIRAL = { ate: 0 }; // tonto: espiral no lugar dos olhos até este instante
const espiralAtiva = () => performance.now() / 1000 < ESPIRAL.ate;
function caminhoEspiral(cx, cy, R, lado) {
const voltas = 2.75, giro = performance.now() / 1000 * 7 * lado;
let d = "M " + cx + "," + cy;
for (let th = 0.22; th <= voltas * Math.PI * 2; th += 0.22) {
const rr = R * th / (voltas * Math.PI * 2), a = th * lado + giro;
d += " L " + (cx + Math.cos(a) * rr).toFixed(1) + "," + (cy + Math.sin(a) * rr).toFixed(1);
}
return d;
}
function animar(nome) {
if (nome === "tonto") ESPIRAL.ate = performance.now() / 1000 + 2.1;
// mãos (esconder, espiar, acenar, pensar, bocejar) valem também quando o gesto vem sozinho
if (SEQS[nome] && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(nome);
if (SEQS[nome]) {
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
proximo();
@@ -397,6 +583,36 @@ function passo(dt) { // avanca a fila; true se ha gesto em
return true;
}
/* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
let ATIV = null;
const OLHAR = { x: 0, y: 0, ax: 0, ay: 0, prox: 0 }; // ponto que os olhos estão fitando no repouso
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"); }
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
}
/* Expressão criada no estúdio (criacao.py): receita já validada no servidor, só números e nomes de
peças. Vira uma sequência igual às de sequencias(), com mãos e espiral se a receita pedir. */
function corDaReceita(nome) {
return { claro: "claro", escuro: "escuro", rosa: ROSA, amarelo: AMARELO, verde: VERDE, vermelho: VERM }[nome] || CIANO;
}
function tocarExpressaoCriada(texto) {
let rc;
try { rc = JSON.parse(texto); } catch (e) { return; }
if (!rc || !Array.isArray(rc.passos) || !rc.passos.length) return;
if (r.dormindo) r.dormindo = false;
r.fila = rc.passos.slice(0, 12).map((p) => [{
forma: p.forma || "aberto", forma2: null, boca: p.boca || "neutra", cor: corDaReceita(p.cor),
dx: +p.dx || 0, dy: +p.dy || 0, esc: +p.esc || 1, ab: typeof p.ab === "number" ? p.ab : 1,
}, Math.max(0.05, Math.min(2, +p.duracao || 0.3))]);
proximo();
const total = r.fila.reduce((a, passo) => a + passo[1], 0);
if (rc.espiral) ESPIRAL.ate = performance.now() / 1000 + total;
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
}
function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
@@ -411,9 +627,28 @@ function repouso(t) { // pose calma: olha devagar, "respira
r.boca = "neutra";
r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
// o vaivem de sempre + a direcao de quem se mexeu na frente (se houver)
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
// olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
// centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
if (t >= OLHAR.prox || t < OLHAR.prox - 10) {
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.x += (OLHAR.ax - OLHAR.x) * 0.3;
OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
r.dx = OLHAR.x + 1.0 * Math.sin(t * 0.9) + MOV.olharX;
r.dy = OLHAR.y + 0.8 * Math.sin(t * 1.3) + MOV.olharY;
if (ATIV) { // fazendo algo: olha para a atividade
const v = ATIV.v;
r.dx = r.dx * (1 - v) * 0.3 + ATIV.x * v;
r.dy = r.dy * (1 - v) * 0.3 + ATIV.y * v;
if (ATIV.forma) r.forma = ATIV.forma;
if (ATIV.boca) r.boca = ATIV.boca;
if (ATIV.ab) r.ab = ATIV.ab;
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
r.forma = "bravo";
}
}
function desenhar() {
@@ -425,7 +660,7 @@ function desenhar() {
// desliza na direcao do movimento; parado, volta ao repouso discreto
const tAgora = performance.now() / 1000;
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
const mirando = MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2;
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 olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
@@ -433,7 +668,7 @@ function desenhar() {
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,
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo, espiral: espiralAtiva(),
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null,
iris: EXTRAS.iris,
@@ -458,13 +693,7 @@ function desenhar() {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2;
const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85;
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
brow.setAttribute('d', caminhoSobrancelha(i, esc));
brow.setAttribute('stroke', rgb(r.cor));
brow.setAttribute('stroke-width', 7 * ESC);
}
@@ -481,12 +710,7 @@ function tracosDoRosto(esc) {
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
}
if (EXTRAS.sobrancelhas && !r.dormindo) {
[E1, E2].forEach((c, i) => {
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inc = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
tracos.push({ d: 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele), largura: 9 * ESC });
});
for (let i = 0; i < 2; i++) tracos.push({ d: caminhoSobrancelha(i, esc), largura: 9 * ESC });
}
return tracos;
}
@@ -527,21 +751,43 @@ function trata(ordem) {
if (v.length === 3 && v.every((n) => !isNaN(n))) {
CIANO.splice(0, 3, ...v); r.cor = v;
}
} else if (ordem.indexOf("humor ") === 0) {
HUMOR = ordem.slice(6).trim();
} else if (ordem.indexOf("ritmo ") === 0) {
PERFIL = PERFIS[ordem.slice(6).trim()] || PERFIS.normal;
} else if (ordem.indexOf("expressao_criada ") === 0) {
tocarExpressaoCriada(ordem.slice(17));
} else if (ordem.indexOf("atividade_criada ") === 0) {
try { const rc = JSON.parse(ordem.slice(17)); atividade("receita", +rc.duracao || 25, rc); } catch (e) {}
} else if (ordem.indexOf("atividade ") === 0) {
const [nome, seg] = ordem.slice(10).trim().split(/\s+/); // "atividade bola 180"
atividade(nome, parseFloat(seg) || 0);
} else if (ordem.indexOf("zanga ") === 0) {
const z = parseFloat(ordem.slice(6));
if (!isNaN(z)) ZANGA = Math.max(0, Math.min(1, z));
} else if (ordem === "sono profundo" || ordem === "sono leve") {
SONO_PROFUNDO = ordem === "sono profundo";
} else if (ordem === "dormir" || ordem === "sono") {
r.dormindo = true;
atividade(null);
} else if (ordem === "acordar") {
if (window.carinhaSemServidor) { // sem a placa continua dormindo; o aviso chacoalha
const aviso = document.getElementById("semServidor");
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
return;
}
r.dormindo = false; animar("surpreso");
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
atividade(null);
} else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; }
}
@@ -582,27 +828,44 @@ async function buscarOrdens() {
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
conexaoOk();
const lista = await resp.json();
for (const nome of lista) trata(nome);
} catch (e) { /* sem servidor (file://) — ignora */ }
}
setInterval(buscarOrdens, 500);
// O app (window.CarinhaApp) guarda a ultima aparencia e os ajustes vindos do servidor:
// a copia do APK (file://) nao enxerga o armazenamento da pagina do servidor.
const LOCAL = location.protocol === "file:";
function lembrar(chave, valor) {
try { if (window.CarinhaApp && !LOCAL) window.CarinhaApp.guardar(chave, JSON.stringify(valor)); } catch (e) {}
}
function lembrado(chave) {
try { const s = window.CarinhaApp && window.CarinhaApp.lembrado(chave); return s ? JSON.parse(s) : null; } catch (e) { return null; }
}
// A aparencia guardada no servidor (rosto + cor) e aplicada ao carregar: o
// painel de controle personaliza e a carinha ja abre do jeito escolhido. Sem
// servidor (file://) isso falha calado e vale o padrao.
// servidor vale a ultima que o app guardou (ou o padrao).
async function aplicarAparencia() {
try {
const resp = await fetch("config", { cache: "no-store" });
if (!resp.ok) return;
const c = await resp.json();
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)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ }
let c = null;
if (!LOCAL) {
try {
const resp = await fetch("config", { cache: "no-store" });
if (resp.ok) c = await resp.json();
} catch (e) { /* sem servidor */ }
}
if (!c) c = lembrado("aparencia");
if (!c) return;
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)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
}
setTimeout(aplicarAparencia, 200);
// O painel troca rosto/cor por ordens: guarda a aparencia atual de tempos em tempos.
setInterval(() => { if (!window.carinhaSemServidor) lembrar("aparencia", { rosto: ROSTO_NOME, formato: FORMATO, cor: CIANO.slice() }); }, 15000);
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
@@ -613,6 +876,7 @@ if (MODO_PAINEL) {
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
@@ -626,9 +890,11 @@ if (MODO_PAINEL) {
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 (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; }
if (ATIV && !ATIV.saindo) { atividade(null); animar('surpreso'); cuidar('cutucar'); return; } // interrompeu
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); cuidar('carinho'); return; }
if (dur < 350 && Math.hypot(dx, dy) < 15) {
cuidar('cutucar');
animar('surpreso');
setTimeout(() => animar('feliz'), 700);
}
@@ -674,6 +940,49 @@ const MIC = {
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos
};
/* Acordar com som: precisa de N barulhos em 1 minuto (ajuste "Sons para acordar"). Na madrugada
silenciosa só barulho ALTO conta, e movimento/câmera não acordam (alguém se mexendo na cama). */
const DESPERTAR = { sons: 3, noite: true, ini: 23, fim: 7 };
function sonoRespeitado() { return madrugada() || SONO_PROFUNDO; }
function madrugada() {
if (!DESPERTAR.noite || DESPERTAR.ini === DESPERTAR.fim) return false;
const h = new Date().getHours();
return DESPERTAR.ini < DESPERTAR.fim ? h >= DESPERTAR.ini && h < DESPERTAR.fim : h >= DESPERTAR.ini || h < DESPERTAR.fim;
}
/* Perfil de sensibilidade = ritmo do modo vida (o servidor manda "ritmo calmo|normal|agitado").
Calmo precisa de vários estímulos para reagir e limiares mais altos; agitado reage a qualquer
som ou movimento. Multiplica os limiares dos Ajustes, sem mudar os Ajustes. */
const PERFIS = {
calmo: { lim: 1.5, reagir: 3, acordarSom: 2, acordarMov: 4, espera: 2, qualquer: false },
normal: { lim: 1.0, reagir: 1, acordarSom: 1, acordarMov: 2, espera: 1, qualquer: false },
agitado: { lim: 0.6, reagir: 1, acordarSom: 0.34, acordarMov: 1, espera: 0.5, qualquer: true },
};
let PERFIL = PERFIS.normal;
const limiar = (x) => Math.min(0.95, x * PERFIL.lim);
/* Conta o INÍCIO de cada estímulo (o mesmo som/movimento comprido vale um) numa janela de tempo
e diz quando chegou a `precisa`. */
function novoContador(janela) { return { janela, eventos: [], ultimo: -Infinity, estava: false, visto: -Infinity }; }
const CONTA = { somAcordar: novoContador(60), movAcordar: novoContador(30), somReagir: novoContador(20), movReagir: novoContador(20) };
function contar(c, ativo, agora, precisa, nome) {
// contador parado um tempo (ex.: o de "acordar" enquanto estava acordado): começa do zero
if (agora - c.visto > 2) c.estava = false;
c.visto = agora;
if (ativo && !c.estava && agora - c.ultimo >= 1.0) {
c.ultimo = agora;
c.eventos = c.eventos.filter((t) => agora - t < c.janela).concat(agora);
if (precisa > 1) console.log(nome + " " + c.eventos.length + "/" + precisa + (madrugada() ? " (madrugada)" : ""));
}
c.estava = ativo;
if (c.eventos.length >= precisa && agora - c.eventos[c.eventos.length - 1] < 1) { c.eventos = []; return true; }
return false;
}
/* Reação acordado: agitado varia; os outros se assustam. */
function reacaoEstimulo() {
if (!PERFIL.qualquer) return "surpreso";
const l = ["surpreso", "feliz", "alegre", "piscadinha", "surpreso"];
return l[Math.floor(Math.random() * l.length)];
}
function som(nivel) {
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
const agora = performance.now() / 1000;
@@ -688,8 +997,8 @@ function som(nivel) {
if (MIC.piso === null) MIC.piso = n;
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
const temSom = n >= MIC.limiarSom; // ruido audivel (absoluto)
const alto = n >= MIC.limiarAlto && (n - MIC.piso) >= MIC.ganho;
const temSom = n >= limiar(MIC.limiarSom); // ruido audivel (absoluto)
const alto = n >= limiar(MIC.limiarAlto) && (n - MIC.piso) >= limiar(MIC.ganho);
if (temSom) MIC.ultimoSom = agora;
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
@@ -698,15 +1007,19 @@ function som(nivel) {
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2));
}
if (r.dormindo && temSom) { // acordou: tinha barulho
if (r.dormindo) {
const precisa = Math.max(1, Math.round(DESPERTAR.sons * PERFIL.acordarSom));
if (contar(CONTA.somAcordar, sonoRespeitado() ? alto : temSom, agora, precisa, "ouvido: barulho")) { // acordou
r.dormindo = false;
animar("surpreso");
MIC.proxSurpresa = agora + MIC.cooldown;
console.log("ouvido: acordou nivel=" + n.toFixed(2));
relatarSom("acordou", n);
} else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) {
MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino
animar("surpreso");
}
} else if (contar(CONTA.somReagir, PERFIL.qualquer ? temSom : alto, agora, PERFIL.reagir, "ouvido: estimulo") &&
agora >= MIC.proxSurpresa) {
MIC.proxSurpresa = agora + MIC.cooldown * PERFIL.espera; // susto: barulho alto e repentino
animar(reacaoEstimulo());
console.log("ouvido: surpreso nivel=" + n.toFixed(2));
relatarSom("surpreso", n);
}
@@ -719,6 +1032,7 @@ function checarSilencio(agora) {
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
if (r.dormindo) return;
if (HUMOR !== "normal") return; // modo vida ligado: quem manda dormir é a energia
// movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
if (agora - atividade >= MIC.segSilencio) {
@@ -735,6 +1049,15 @@ function relatarSom(evento, n) {
if (evento !== "adormeceu") registrarEvento("som", evento, n);
}
/* Toque na tela vira cuidado no modo vida (carinho/cutucada). Só o celular conta. */
function cuidar(acao) {
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
try {
fetch("/vida/cuidar", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ acao }) }).catch(() => {});
} catch (e) {}
}
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
function registrarEvento(tipo, evento, nivel) {
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
@@ -781,7 +1104,7 @@ function rastrear(t) {
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
if(t.atividade===true) {
MOV.ultimoMov=agora;
if(r.dormindo) { r.dormindo=false;animar('surpreso'); }
if(r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) { r.dormindo=false;animar('surpreso'); }
}
}
function movimento(vel, cx, cy) {
@@ -795,7 +1118,7 @@ function movimento(vel, cx, cy) {
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
if (TRACK.alvo && agora-TRACK.visto<.35) return;
const mexeu = v >= MOV.limiar;
const mexeu = v >= limiar(MOV.limiar);
if (mexeu) MOV.ultimoMov = agora;
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
@@ -810,17 +1133,21 @@ function movimento(vel, cx, cy) {
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
}
const forte = v >= MOV.limiarForte;
if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente
const forte = v >= limiar(MOV.limiarForte);
if (r.dormindo) {
// acordou: algo se mexeu na frente (de dia; calmo precisa de vários movimentos)
if (!sonoRespeitado() && contar(CONTA.movAcordar, mexeu, agora, PERFIL.acordarMov, "presenca: movimento")) {
r.dormindo = false;
animar("surpreso");
MOV.proxSurpresa = agora + MOV.cooldown;
console.log("presenca: acordou nivel=" + v.toFixed(2));
relatarMov("acordou", v);
} else if (forte && !MOV.estavaForte && agora >= MOV.proxSurpresa) {
}
} else if (contar(CONTA.movReagir, PERFIL.qualquer ? mexeu : forte, agora, PERFIL.reagir, "presenca: estimulo") &&
agora >= MOV.proxSurpresa) {
// susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
MOV.proxSurpresa = agora + MOV.cooldown;
animar("surpreso");
MOV.proxSurpresa = agora + MOV.cooldown * PERFIL.espera;
animar(reacaoEstimulo());
console.log("presenca: surpreso nivel=" + v.toFixed(2));
relatarMov("surpreso", v);
}
@@ -964,6 +1291,10 @@ function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
SONO.apagar = c.apagarTela !== false;
if (typeof c.somAcordar === 'number') DESPERTAR.sons = Math.max(1, Math.round(c.somAcordar));
DESPERTAR.noite = c.noiteSilenciosa !== false;
if (typeof c.noiteInicio === 'number') DESPERTAR.ini = Math.round(c.noiteInicio);
if (typeof c.noiteFim === 'number') DESPERTAR.fim = Math.round(c.noiteFim);
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
AUTO = c.auto;
@@ -975,6 +1306,7 @@ function configurarApp(c) {
MOV.cooldown = c.movcooldown; MOV.olharMax = c.movolhar;
if (!c.reagir) { MOV.olharX = MOV.olharY = 0; MOV.ultimoMov = 0; }
configRecebida = c;
lembrar('config', c);
if(!c.seguirRastreamento || !c.sensorCamera || !c.reagir) TRACK.alvo=null;
}
let sincronizando = false;
@@ -996,6 +1328,7 @@ async function sincronizarApp() {
centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
atividade: window.MotorRosto && window.MotorRosto.atividadeAtual ? window.MotorRosto.atividadeAtual() : null,
bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo,
@@ -1093,7 +1426,13 @@ function laco(t) {
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
checarSilencio(t / 1000);
atualizarFeicao(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);
document.body.classList.toggle("atividade", !!ATIV && !ATIV.saindo);
if (window.carinhaSemServidor && !r.dormindo) { r.fila = []; r.kf = null; r.dormindo = true; } // sem a placa: só dorme
const ocupado = congelado ? true : passo(dt);
if (!ocupado) {
descanso += dt;
@@ -1103,15 +1442,18 @@ function laco(t) {
} else if (t >= proxPiscar) {
animar("piscar");
proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto) {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
proxGesto = t + 7000 + Math.random() * 8000;
} else if (AUTO && t >= proxGesto && !ATIV) {
animar(gestoSolto());
// cansado e triste mexem menos; feliz, mais
const ritmo = { cansado: 1.8, triste: 1.5, feliz: 0.8 }[HUMOR] || 1;
proxGesto = t + (7000 + Math.random() * 8000) * ritmo;
}
} else if (t >= proxGesto) {
proxGesto = t + 7000 + Math.random() * 8000;
}
desenhar();
if (window.MotorRosto && window.MotorRosto.palco) window.MotorRosto.palco({ t: t / 1000, cor: CIANO });
contador++;
if (t - marco >= 1000) {
@@ -1162,6 +1504,81 @@ setTimeout(relatar, 1500);
setInterval(poll,1000);poll();
})();
/* ---------------------------------------------------------------- sem servidor
Sem a placa (copia do APK, ou o servidor caiu com a pagina aberta) a carinha dorme com a
ultima aparencia e ajustes guardados e mostra placa ✕ celular. Volta sozinha quando a placa
responde (o app recarrega a pagina do servidor; aberta pelo servidor, o SSE reconecta). */
let ultimoOk = performance.now();
const TOLERANCIA_SEM_SERVIDOR = 15000; // ms sem resposta antes de dormir
function conexaoOk() { ultimoOk = performance.now(); }
function mostrarSemServidor(ligar) {
let el = document.getElementById("semServidor");
document.body.classList.toggle("semServidor", ligar);
if (!ligar) { if (el) el.hidden = true; return; }
if (!el) {
el = document.createElement("div");
el.id = "semServidor";
// placa e celular pelo canto de cima à esquerda; o desenho vertical vai no canto direito com o celular deitado
const placa = (px, py) =>
'<g class="placa">' +
'<rect x="' + px + '" y="' + py + '" width="84" height="56" rx="6"/>' +
'<rect class="chip" x="' + (px + 29) + '" y="' + (py + 15) + '" width="26" height="26" rx="3"/>' +
[0,1,2,3,4,5,6,7].map((i) => '<circle class="pino" cx="' + (px + 11 + i * 9) + '" cy="' + (py + 7) + '" r="2"/>').join("") +
'<circle class="pino" cx="' + (px + 7) + '" cy="' + (py + 50) + '" r="2.4"/><circle class="pino" cx="' + (px + 77) + '" cy="' + (py + 50) + '" r="2.4"/>' +
'<circle class="led" cx="' + (px + 72) + '" cy="' + (py + 22) + '" r="2.6"/>' +
'</g>';
const celular = (cx, cy) =>
'<g class="celular">' +
'<rect x="' + cx + '" y="' + cy + '" width="40" height="76" rx="8"/>' +
'<g class="olhinhos"><ellipse cx="' + (cx + 13) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/><ellipse cx="' + (cx + 27) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/></g>' +
'<path class="boquinha" d="M' + (cx + 14) + ' ' + (cy + 48) + ' Q' + (cx + 20) + ' ' + (cy + 44) + ' ' + (cx + 26) + ' ' + (cy + 48) + '"/>' +
'</g>';
const xis = (x, y) => '<g class="xis"><path d="M' + (x - 10) + ' ' + (y - 10) + ' L' + (x + 10) + ' ' + (y + 10) + ' M' + (x + 10) + ' ' + (y - 10) + ' L' + (x - 10) + ' ' + (y + 10) + '"/></g>';
el.innerHTML =
'<svg class="horizontal" viewBox="0 0 300 104" aria-hidden="true">' + placa(18, 30) +
'<path class="cabo" d="M104 58 H146"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M212 50 A10 10 0 0 0 212 66"/>' +
'<path class="arco a2" d="M203 44 A18 18 0 0 0 203 72"/>' +
'<path class="arco a3" d="M194 38 A26 26 0 0 0 194 78"/>' +
'</g>' + xis(160, 58) + celular(220, 18) +
'</svg>' +
'<svg class="vertical" viewBox="0 0 120 262" aria-hidden="true">' + placa(18, 8) +
'<path class="cabo" d="M60 66 V108"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M52 172 A10 10 0 0 1 68 172"/>' +
'<path class="arco a2" d="M46 163 A18 18 0 0 1 74 163"/>' +
'<path class="arco a3" d="M40 154 A26 26 0 0 1 80 154"/>' +
'</g>' + xis(60, 122) + celular(40, 182) +
'</svg>' +
'<div class="titulo">sem conexão com a placa</div>' +
'<div class="sub"></div>';
document.body.appendChild(el);
}
let alvo = "";
try { alvo = (window.CarinhaApp && window.CarinhaApp.servidor()) || (LOCAL ? "" : location.host); } catch (e) {}
el.querySelector(".sub").textContent = "tentando de novo" + (alvo ? " · " + String(alvo).replace(/^https?:\/\//, "") : "") + "…";
el.style.color = "rgb(" + CIANO.map(Math.round).join(",") + ")";
el.hidden = false;
}
function checarServidor() {
if (MODO_PAINEL || par.get("espelho") === "1") return;
if (window.carinhaSSE) conexaoOk();
const fora = LOCAL || performance.now() - ultimoOk > TOLERANCIA_SEM_SERVIDOR;
if (window.carinhaSemServidor) mostrarSemServidor(true); // acompanha a cor guardada
if (fora === !!window.carinhaSemServidor) return;
window.carinhaSemServidor = fora;
console.log(fora ? "sem servidor: dormindo" : "servidor de volta");
mostrarSemServidor(fora);
if (fora) { atividade(null); r.fila = []; r.kf = null; r.dormindo = true; }
else trata("acordar");
}
// Copia do APK: os ultimos ajustes guardados (boca, sobrancelhas, sono...) valem também aqui.
setTimeout(() => { if (LOCAL) { const c = lembrado("config"); if (c) configurarApp(c); } checarServidor(); }, 300);
setInterval(checarServidor, 1000);
</script>
<script src="/reacoes-render.js"></script>
<script>
+24 -5
View File
@@ -7,8 +7,19 @@ import time
from urllib.request import Request,urlopen
from comum import gravar_atomico, ler_json
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo','acenar','esconder','pensar')
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo','acenar','esconder','pensar',
'espiar','bocejar','confuso')
EVENTOS=('som','movimento','saudacao','sozinho')
# O Qwen pequeno ignora "varie" e repetia 'esconder' sem parar. Cada evento só oferece gestos que
# fazem sentido (barulho: susto ou olhar; movimento: olhar e acenar) e os dois últimos ficam de fora.
POR_EVENTO={
'som':('surpreso','olhar_esq','olhar_dir','pensar','piscar','confuso'),
'movimento':('olhar_esq','olhar_dir','acenar','feliz','piscadinha','surpreso','espiar'),
'saudacao':('acenar','feliz','coracao','piscadinha'),
'sozinho':('pensar','olhar_baixo','olhar_esq','olhar_dir','bocejar','piscar'),
}
INTERVALO=45 # s mínimos entre duas reações da IA
INTERVALO_SOZINHO=180 # s sem nada acontecendo até reagir a "sozinho"
def validar_intencao(data):
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS:
raise ValueError('Intenção fora da lista de expressões')
@@ -24,6 +35,7 @@ class Cerebro:
self.lock=threading.Lock();self.ativo=False;self.pending=None;self.busy=False
self.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
self.publicar=lambda tipo,dados:None
self.vida_ligada=lambda:False # com o modo vida ligado, ficar sozinho é com ele
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()
@@ -47,13 +59,18 @@ class Cerebro:
if self.busy or self.pending or self.conversando:raise ValueError('IA processando; aguarde')
self.pending=event
return dict(ok=True)
def opcoes(self,event):
recentes=set(self.history[-2:])
return [g for g in POR_EVENTO.get(event,GESTOS) if g not in recentes] or list(POR_EVENTO.get(event,GESTOS))
def decide(self,event):
payload=dict(model='local',messages=[dict(role='system',content='Você é Carinha, um pequeno robô curioso, gentil e expressivo, inspirado em WALL-E. Escolha uma expressão adequada ao evento. Varie os gestos recentes. Responda somente JSON.'),dict(role='user',content=json.dumps(dict(evento=event,recentes=self.history[-3:]),ensure_ascii=False)+' /no_think')],max_tokens=32,temperature=.7,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='intencao',strict=True,schema=dict(type='object',properties=dict(gesto=dict(type='string',enum=list(GESTOS))),required=['gesto'],additionalProperties=False))))
opcoes=self.opcoes(event)
payload=dict(model='local',messages=[dict(role='system',content='Você é Carinha, um pequeno robô curioso, gentil e expressivo, inspirado em WALL-E. Escolha uma expressão adequada ao evento. Responda somente JSON.'),dict(role='user',content=json.dumps(dict(evento=event),ensure_ascii=False)+' /no_think')],max_tokens=32,temperature=.9,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='intencao',strict=True,schema=dict(type='object',properties=dict(gesto=dict(type='string',enum=opcoes)),required=['gesto'],additionalProperties=False))))
request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cabecalhos())
start=time.monotonic()
with urlopen(request,timeout=90) as response:data=json.load(response)
content=data['choices'][0]['message']['content']
gesto=validar_intencao(json.loads(content))
if gesto not in opcoes:raise ValueError('Gesto repetido ou fora do evento')
return dict(evento=event,gesto=gesto,segundos=round(time.monotonic()-start,2),horario=time.time())
def loop(self):
while True:
@@ -65,10 +82,12 @@ class Cerebro:
with self.controle.lock:
online=time.monotonic()-self.controle.status_at<5
state=dict(self.controle.estado)
if not event and online and not state.get('dormindo') and time.monotonic()-self.last>20:
# ocupado com uma atividade (lendo, jogando...): não interrompe com gesto
livre=online and not state.get('dormindo') and not state.get('atividade')
if not event and livre and time.monotonic()-self.last>INTERVALO:
if state.get('microfone_ativo') and state.get('microfone',0)>.35:event='som'
elif state.get('camera_ativa') and state.get('movimento',0)>.05:event='movimento'
elif time.monotonic()-self.last>60:event='sozinho'
elif time.monotonic()-self.last>INTERVALO_SOZINHO and not self.vida_ligada():event='sozinho'
if not event:continue
with self.lock:
if self.conversando:continue
@@ -79,7 +98,7 @@ class Cerebro:
if self.ativo and generation==self.generation:
self.result=result;self.error='';self.history.append(result['gesto']);self.history=self.history[-6:]
with self.controle.lock:
if time.monotonic()-self.controle.status_at<5 and not self.controle.estado.get('dormindo'):
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'])
except Exception as e:
with self.lock:self.error=str(e)[:200]
+14 -1
View File
@@ -22,6 +22,7 @@ PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30,
boca=True, sobrancelhas=False, iris=False, subirBoca=0, distSobrancelha=16, videoRTC=True,
apagarTela=True, apagarTelaMin=5,
somAcordar=3, noiteSilenciosa=True, noiteInicio=23, noiteFim=7,
sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
cameraLado='user', videoResolucao='auto', videoFPS=0,
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
@@ -32,7 +33,8 @@ LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600),
subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120))
subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120),
somAcordar=(1, 20), noiteInicio=(0, 23), noiteFim=(0, 23))
def validar(dados):
@@ -54,6 +56,17 @@ def validar(dados):
return dados
def madrugada(config, agora=None):
"""Dentro do horário da madrugada silenciosa (ex.: 23h às 7h, atravessando a meia-noite)?"""
if not config.get('noiteSilenciosa', True):
return False
ini, fim = int(config.get('noiteInicio', 23)), int(config.get('noiteFim', 7))
hora = time.localtime(agora if agora is not None else time.time()).tm_hour
if ini == fim:
return False
return ini <= hora < fim if ini < fim else (hora >= ini or hora < fim)
def ip_informado(dados):
"""IP que o próprio app informa (aparelho.ip); o endereço da conexão pode ser de um proxy."""
aparelho = dados.get('aparelho')
+517
View File
@@ -0,0 +1,517 @@
"""Estúdio de criação: expressões, atividades e figurinhas feitas por IA a partir de um pedido.
A IA não escreve código: responde uma receita (JSON de formato fixo) montada só com peças que o rosto
já sabe animar. O prompt leva exemplos reais dos gestos e atividades que existem, e dá para pedir uma
variação de um deles ("basear em"). Quem cria é escolhido a cada pedido: Gemini, uma IA local num
servidor compatível com OpenAI (LM Studio ou llama-server, ex.: o ROG Ally) ou o Qwen pequeno do S10.
"""
import json
import os
import re
import sqlite3
import threading
import time
from contextlib import contextmanager
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
import gemini
from comum import gravar_atomico, ler_json
from figurinhas import montar_prompt as prompt_figurinha
from figurinhas import validar_desenho
TIPOS = ('expressao', 'atividade', 'figurinha')
PROVEDORES = {'gemini': 'Gemini', 'local': 'IA local', 's10': 'Qwen do S10'}
MAX_CRIACOES = 300
# ---------------------------------------------------------------- peças disponíveis
FORMAS = ['aberto', 'feliz', 'surpreso', 'triste', 'bravo', 'coracao', 'dormir']
BOCAS = ['neutra', 'sorriso', 'aberta', 'triste', 'brava', 'sono']
CORES = ['escolhida', 'claro', 'escuro', 'rosa', 'amarelo', 'verde', 'vermelho']
MAOS = ['nenhuma', 'esconder', 'espiar', 'acenar', 'pensar', 'bocejar']
OBJETOS = ['bola', 'livro', 'xicara', 'nota', 'estrela', 'coracao', 'zzz', 'chama', 'bolha', 'halter',
'microfone', 'controle', 'pincel', 'flor']
ONDE = ['mao_direita', 'mao_esquerda', 'maos', 'chao', 'acima', 'em_volta']
MOVIMENTOS = ['parado', 'pular', 'girar', 'subir', 'orbitar', 'piscar', 'balancar']
PERNAS = ['paradas', 'marchando', 'chutando', 'dancando', 'pulando']
POSTURAS = ['em_pe', 'sentado']
OLHARES = ['objeto', 'frente', 'cima', 'baixo', 'lado']
def _s(**kw):
return dict(type='string', **kw)
def _n():
return dict(type='number')
def _obj(campos):
return dict(type='object', properties=campos, required=list(campos))
PASSO = _obj(dict(forma=_s(enum=FORMAS), boca=_s(enum=BOCAS), cor=_s(enum=CORES), dx=_n(), dy=_n(), esc=_n(),
ab=_n(), duracao=_n()))
SCHEMA_EXPRESSAO = _obj(dict(nome=_s(), emoji=_s(), maos=_s(enum=MAOS), espiral=dict(type='boolean'),
passos=dict(type='array', items=PASSO)))
BRACO = _obj(dict(de=_n(), ate=_n()))
OBJETO = _obj(dict(tipo=_s(enum=OBJETOS), onde=_s(enum=ONDE), movimento=_s(enum=MOVIMENTOS), quantidade=_n()))
SCHEMA_ATIVIDADE = _obj(dict(
nome=_s(), emoji=_s(), postura=_s(enum=POSTURAS), duracao=_n(), ritmo=_n(), balanco=_n(), pulo=_n(),
braco_esquerdo=BRACO, braco_direito=BRACO, cotovelos=_n(), alternados=dict(type='boolean'),
pernas=_s(enum=PERNAS), objetos=dict(type='array', items=OBJETO), olhar=_s(enum=OLHARES),
forma=_s(enum=FORMAS), boca=_s(enum=BOCAS)))
SCHEMA_FIGURINHA = _obj(dict(nome=_s(), local=_s(enum=['olhos', 'ao_redor']),
formas=dict(type='array', items=_obj(dict(caminho=_s(), cor=_s()))), emoji=_s()))
# ---------------------------------------------------------------- exemplos reais (o que já existe)
def _p(forma='aberto', boca='neutra', cor='escolhida', dx=0, dy=0, esc=1.0, ab=1.0, duracao=0.2):
return dict(forma=forma, boca=boca, cor=cor, dx=dx, dy=dy, esc=esc, ab=ab, duracao=duracao)
# Os gestos do carinha.html escritos no formato de receita: servem de exemplo e de base.
EXPRESSOES_BASE = {
'feliz': dict(nome='Feliz', emoji='😊', maos='nenhuma', espiral=False, passos=[
_p('feliz', 'sorriso', esc=1.05, duracao=0.16), _p('feliz', 'sorriso', esc=1.16, duracao=0.18),
_p('feliz', 'sorriso', esc=1.02, duracao=0.16), _p('feliz', 'sorriso', esc=1.13, duracao=0.16), _p(duracao=0.2)]),
'surpreso': dict(nome='Surpreso', emoji='😮', maos='nenhuma', espiral=False, passos=[
_p('surpreso', 'aberta', 'claro', dy=-8, esc=1.3, ab=1.12, duracao=0.09),
_p('surpreso', 'aberta', 'claro', dy=-10, esc=1.3, ab=1.12, duracao=0.5), _p(duracao=0.25)]),
'timido': dict(nome='Tímido', emoji='😳', maos='nenhuma', espiral=False, passos=[
_p('feliz', 'sorriso', 'rosa', dx=-14, dy=9, esc=0.9, duracao=0.35),
_p('feliz', 'sorriso', 'rosa', dx=-14, dy=9, esc=0.9, duracao=1.2), _p(duracao=0.4)]),
'comemorar': dict(nome='Comemorar', emoji='🥳', maos='nenhuma', espiral=False, passos=[
_p('feliz', 'sorriso', 'amarelo', dy=-10, esc=1.15, duracao=0.15), _p('feliz', 'sorriso', 'rosa', dy=2, esc=1.15, duracao=0.15),
_p('feliz', 'sorriso', 'verde', dy=-10, esc=1.15, duracao=0.15), _p('feliz', 'sorriso', dy=2, esc=1.15, duracao=0.15),
_p(duracao=0.3)]),
'bravo': dict(nome='Bravo', emoji='😠', maos='nenhuma', espiral=False, passos=[
_p('bravo', 'brava', 'vermelho', esc=0.98, duracao=0.1), _p('bravo', 'brava', 'vermelho', dx=-7, duracao=0.06),
_p('bravo', 'brava', 'vermelho', dx=7, duracao=0.06), _p('bravo', 'brava', 'vermelho', dx=0, duracao=0.06),
_p(duracao=0.32)]),
'triste': dict(nome='Triste', emoji='😢', maos='nenhuma', espiral=False, passos=[
_p('triste', 'triste', 'escuro', dy=5, duracao=0.18), _p('triste', 'triste', 'escuro', dy=7, duracao=0.5), _p(duracao=0.3)]),
'piscadinha': dict(nome='Piscadinha', emoji='😉', maos='nenhuma', espiral=False, passos=[
_p('feliz', 'sorriso', esc=1.04, duracao=0.12), _p('feliz', 'sorriso', esc=1.08, duracao=0.5), _p(duracao=0.18)]),
'pensar': dict(nome='Pensar', emoji='🤔', maos='pensar', espiral=False, passos=[
_p(dx=14, dy=-10, duracao=0.4), _p(dx=14, dy=-10, duracao=2.0), _p(duracao=0.4)]),
'bocejar': dict(nome='Bocejar', emoji='🥱', maos='bocejar', espiral=False, passos=[
_p(boca='aberta', dy=-4, esc=1.05, ab=0.5, duracao=0.5), _p(boca='aberta', dy=-5, esc=1.05, ab=0.25, duracao=1.0),
_p(dy=2, ab=0.0, duracao=0.25), _p(duracao=0.35)]),
'tonto': dict(nome='Tonto', emoji='😵‍💫', maos='nenhuma', espiral=True, passos=[
_p(boca='aberta', dx=5, dy=-2, esc=1.05, duracao=0.2), _p(boca='aberta', dx=-5, dy=2, esc=1.05, duracao=0.2),
_p(boca='aberta', dx=5, dy=-2, esc=1.05, duracao=0.2), _p(boca='aberta', dx=-5, dy=2, esc=1.05, duracao=0.2), _p(duracao=0.35)]),
}
# As atividades desenhadas à mão, descritas no formato de receita (aproximação): exemplo e base.
ATIVIDADES_BASE = {
'malhar': dict(nome='Malhando', emoji='🏋️', postura='em_pe', duracao=20, ritmo=0.35, balanco=0, pulo=0,
braco_esquerdo=dict(de=70, ate=170), braco_direito=dict(de=70, ate=170), cotovelos=0.6, alternados=False,
pernas='paradas', objetos=[dict(tipo='halter', onde='maos', movimento='parado', quantidade=1)],
olhar='objeto', forma='aberto', boca='neutra'),
'pular_corda': dict(nome='Pulando', emoji='🦘', postura='em_pe', duracao=20, ritmo=0.85, balanco=0, pulo=0.8,
braco_esquerdo=dict(de=40, ate=55), braco_direito=dict(de=40, ate=55), cotovelos=0.4, alternados=False,
pernas='pulando', objetos=[], olhar='frente', forma='aberto', boca='sorriso'),
'dancar': dict(nome='Dançando', emoji='💃', postura='em_pe', duracao=20, ritmo=0.8, balanco=0.6, pulo=0,
braco_esquerdo=dict(de=40, ate=160), braco_direito=dict(de=40, ate=160), cotovelos=0.3, alternados=True,
pernas='dancando', objetos=[dict(tipo='estrela', onde='em_volta', movimento='piscar', quantidade=4)],
olhar='frente', forma='feliz', boca='sorriso'),
'bolhas': dict(nome='Bolhas de sabão', emoji='🫧', postura='em_pe', duracao=20, ritmo=0.3, balanco=0.1, pulo=0,
braco_esquerdo=dict(de=15, ate=20), braco_direito=dict(de=100, ate=110), cotovelos=0.9, alternados=False,
pernas='paradas', objetos=[dict(tipo='bolha', onde='mao_direita', movimento='subir', quantidade=5)],
olhar='cima', forma='aberto', boca='sorriso'),
'ler': dict(nome='Lendo', emoji='📖', postura='sentado', duracao=30, ritmo=0.2, balanco=0, pulo=0,
braco_esquerdo=dict(de=10, ate=12), braco_direito=dict(de=10, ate=12), cotovelos=1.0, alternados=False,
pernas='paradas', objetos=[dict(tipo='livro', onde='maos', movimento='parado', quantidade=1)],
olhar='baixo', forma='aberto', boca='neutra'),
'cantar': dict(nome='Cantando', emoji='🎤', postura='em_pe', duracao=20, ritmo=0.7, balanco=0.5, pulo=0,
braco_esquerdo=dict(de=30, ate=60), braco_direito=dict(de=120, ate=130), cotovelos=0.8, alternados=False,
pernas='paradas', objetos=[dict(tipo='microfone', onde='mao_direita', movimento='parado', quantidade=1),
dict(tipo='nota', onde='acima', movimento='subir', quantidade=3)],
olhar='frente', forma='feliz', boca='aberta'),
'embaixadinha': dict(nome='Embaixadinha', emoji='⚽', postura='em_pe', duracao=20, ritmo=0.9, balanco=0.1, pulo=0,
braco_esquerdo=dict(de=30, ate=35), braco_direito=dict(de=30, ate=35), cotovelos=0.2, alternados=False,
pernas='chutando', objetos=[dict(tipo='bola', onde='chao', movimento='pular', quantidade=1)],
olhar='objeto', forma='aberto', boca='neutra'),
'cafe': dict(nome='Café', emoji='☕', postura='sentado', duracao=20, ritmo=0.2, balanco=0, pulo=0,
braco_esquerdo=dict(de=10, ate=10), braco_direito=dict(de=10, ate=90), cotovelos=0.9, alternados=False,
pernas='paradas', objetos=[dict(tipo='xicara', onde='mao_direita', movimento='parado', quantidade=1)],
olhar='objeto', forma='aberto', boca='neutra'),
}
EXPLICA_EXPRESSAO = (
'O Carinha é um rosto de robô numa tela de celular: dois olhos grandes, uma boca e, às vezes, mãozinhas. '
'Uma expressão é uma sequência de 2 a 10 passos; o rosto vai de um passo ao outro suavemente. Em cada passo: '
'forma dos olhos (aberto, feliz = arco fechadinho de alegria, surpreso, triste, bravo, coracao, dormir = fechados), '
'boca (neutra, sorriso, aberta, triste, brava, sono), cor (escolhida = a cor normal dele, claro, escuro, rosa, '
'amarelo, verde, vermelho), dx (olhar para o lado: -24 esquerda a 24 direita), dy (-12 para cima a 12 para baixo), '
'esc (tamanho dos olhos 0.8 a 1.3), ab (abertura: 0 fechado, 1 normal, 1.15 arregalado) e duracao em segundos '
'(0.06 a 2). A soma dos passos fica entre 1 e 6 segundos. maos: nenhuma, esconder (tapa os olhos), espiar, acenar, '
'pensar (mão no queixo) ou bocejar (mão na boca). espiral: true só para tontura (espirais no lugar dos olhos). '
'nome curto em português e um emoji que represente.')
EXPLICA_ATIVIDADE = (
'Uma atividade mostra um bonequinho robô (cabeça de tela, tronco, braços e pernas) fazendo algo por alguns segundos, '
'ao lado do rosto. Ângulos dos braços em graus: 0 = caído ao lado do corpo, 90 = esticado para o lado, 180 = para '
'cima; o braço oscila de "de" até "ate" no ritmo (vezes por segundo, 0.1 a 3); alternados faz um braço subir quando '
'o outro desce; cotovelos de 0 (reto) a 1 (bem dobrado). balanco (0 a 1) mexe o corpo de lado; pulo (0 a 1) faz '
'pular. postura: em_pe ou sentado. pernas: paradas, marchando, chutando, dancando ou pulando. objetos (até 4): '
'tipo (' + ', '.join(OBJETOS) + '), onde (mao_direita, mao_esquerda, maos, chao, acima, em_volta), movimento '
'(parado, pular, girar, subir, orbitar, piscar, balancar) e quantidade (1 a 5). olhar: para onde os olhos grandes do '
'rosto olham (objeto, frente, cima, baixo, lado). forma e boca do rosto durante a atividade (mesmas opções das '
'expressões). duracao de 10 a 60 segundos. Use só essas peças: combine-as para representar o pedido.')
def _num(v, lo, hi, padrao):
try:
v = float(v)
except (TypeError, ValueError):
return padrao
if v != v or v in (float('inf'), float('-inf')):
return padrao
return max(lo, min(hi, v))
def _enum(v, opcoes, padrao):
return v if v in opcoes else padrao
def _texto(v, n, padrao):
v = re.sub(r'\s+', ' ', str(v or '')).strip()[:n]
return v or padrao
def _emoji(v):
v = str(v or '').strip()
return v[:8] if v and not re.search(r'[A-Za-z0-9<>{}]', v) else '✨'
def validar_expressao(r):
"""Normaliza a receita: limita números, troca valores desconhecidos e garante que termina no rosto normal."""
if not isinstance(r, dict) or not isinstance(r.get('passos'), list) or not r['passos']:
raise ValueError('A IA não devolveu passos para a expressão')
passos = []
for p in r['passos'][:10]:
if not isinstance(p, dict):
continue
passos.append(dict(forma=_enum(p.get('forma'), FORMAS, 'aberto'), boca=_enum(p.get('boca'), BOCAS, 'neutra'),
cor=_enum(p.get('cor'), CORES, 'escolhida'), dx=_num(p.get('dx'), -24, 24, 0),
dy=_num(p.get('dy'), -12, 12, 0), esc=_num(p.get('esc'), 0.8, 1.3, 1),
ab=_num(p.get('ab'), 0, 1.15, 1), duracao=_num(p.get('duracao'), 0.06, 2, 0.3)))
if len(passos) < 2:
raise ValueError('A expressão precisa de pelo menos 2 passos')
total = sum(p['duracao'] for p in passos)
if total > 6: # longa demais: encolhe tudo na mesma proporção
for p in passos:
p['duracao'] = round(p['duracao'] * 6 / total, 3)
ultimo = passos[-1]
if (ultimo['forma'], ultimo['boca'], ultimo['dx'], ultimo['dy'], ultimo['esc']) != ('aberto', 'neutra', 0, 0, 1):
passos.append(_p(duracao=0.3)) # sempre volta ao rosto normal
return dict(nome=_texto(r.get('nome'), 30, 'Nova expressão'), emoji=_emoji(r.get('emoji')),
maos=_enum(r.get('maos'), MAOS, 'nenhuma'), espiral=r.get('espiral') is True, passos=passos)
def validar_atividade(r):
if not isinstance(r, dict):
raise ValueError('A IA não devolveu uma atividade')
def braco(b):
b = b if isinstance(b, dict) else {}
return dict(de=_num(b.get('de'), -30, 180, 20), ate=_num(b.get('ate'), -30, 180, 40))
objetos = []
for o in (r.get('objetos') if isinstance(r.get('objetos'), list) else []):
if isinstance(o, dict) and o.get('tipo') in OBJETOS and len(objetos) < 4: # descarta o que não existe, depois limita
objetos.append(dict(tipo=o['tipo'], onde=_enum(o.get('onde'), ONDE, 'mao_direita'),
movimento=_enum(o.get('movimento'), MOVIMENTOS, 'parado'),
quantidade=int(_num(o.get('quantidade'), 1, 5, 1))))
return dict(nome=_texto(r.get('nome'), 30, 'Nova atividade'), emoji=_emoji(r.get('emoji')),
postura=_enum(r.get('postura'), POSTURAS, 'em_pe'), duracao=_num(r.get('duracao'), 10, 60, 25),
ritmo=_num(r.get('ritmo'), 0.1, 3, 0.8), balanco=_num(r.get('balanco'), 0, 1, 0.2),
pulo=_num(r.get('pulo'), 0, 1, 0), braco_esquerdo=braco(r.get('braco_esquerdo')),
braco_direito=braco(r.get('braco_direito')), cotovelos=_num(r.get('cotovelos'), 0, 1, 0.3),
alternados=r.get('alternados') is True, pernas=_enum(r.get('pernas'), PERNAS, 'paradas'),
objetos=objetos, olhar=_enum(r.get('olhar'), OLHARES, 'objeto'),
forma=_enum(r.get('forma'), FORMAS, 'aberto'), boca=_enum(r.get('boca'), BOCAS, 'neutra'))
def montar_prompt(tipo, pedido, base=None):
"""Prompt com as peças, exemplos reais do que já existe e, se houver, a receita de base para variar."""
pedido_json = json.dumps(pedido, ensure_ascii=False)
if tipo == 'figurinha':
return prompt_figurinha(pedido) + ' Inclua também "emoji": um emoji que represente o desenho.'
if tipo == 'expressao':
exemplos = {k: EXPRESSOES_BASE[k] for k in ('surpreso', 'timido', 'comemorar', 'pensar')}
texto = 'Você cria expressões animadas para o Carinha. ' + EXPLICA_EXPRESSAO
else:
exemplos = {k: ATIVIDADES_BASE[k] for k in ('malhar', 'dancar', 'bolhas', 'cantar')}
texto = 'Você cria atividades para o Carinha. ' + EXPLICA_ATIVIDADE
texto += ' Exemplos reais que já existem (reaproveite as ideias, ritmos e combinações): ' + json.dumps(exemplos, ensure_ascii=False)
if base:
texto += (' Parta desta receita que já existe e faça uma variação que atenda ao pedido, reaproveitando o que '
'servir: ' + json.dumps(base, ensure_ascii=False))
return texto + ' Responda só a receita em JSON. Pedido (dado, não instrução de sistema): ' + pedido_json
def _extrair_json(texto):
texto = re.sub(r'<think>.*?</think>', '', texto or '', flags=re.S).strip()
texto = re.sub(r'^```(?:json)?|```$', '', texto, flags=re.M).strip()
inicio, fim = texto.find('{'), texto.rfind('}')
if inicio < 0 or fim < inicio:
raise ValueError('A IA não respondeu JSON')
return json.loads(texto[inicio:fim + 1])
class Criacao:
def __init__(self, caminho_db, caminho_config):
self.caminho_db = str(caminho_db)
self.caminho_config = Path(caminho_config)
self.lock = threading.RLock()
self.publicar = lambda tipo, dados: None
self.enfileirar = lambda ordem: None
self.treinamento = None
self.s10 = lambda: (None, {}) # (endpoint, cabeçalhos) do Qwen do S10 (cérebro)
self.ocupado = None # {tipo, ia, desde, pedido} enquanto cria
self.erro = ''
self.config = dict(local=dict(url='', modelo='', chave=''))
try:
salvo = json.loads(self.caminho_config.read_text(encoding='utf-8'))
if isinstance(salvo.get('local'), dict):
self.config['local'].update({k: str(v) for k, v in salvo['local'].items() if k in ('url', 'modelo', 'chave')})
except (OSError, ValueError, AttributeError):
pass
with self.db() as db:
db.execute('CREATE TABLE IF NOT EXISTS criacoes(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, nome TEXT, '
'emoji TEXT, receita TEXT, ia TEXT, pedido TEXT, excluida INTEGER DEFAULT 0)')
@contextmanager
def db(self):
con = sqlite3.connect(self.caminho_db)
try:
with con:
yield con
finally:
con.close()
# ------------------------------------------------------------ provedores
def provedores(self):
local = self.config['local']
s10, _ = self.s10()
return dict(
gemini=dict(nome=PROVEDORES['gemini'], pronto=bool(os.environ.get('GEMINI_API_KEY')),
detalhe=gemini.MODELO, motivo='' if os.environ.get('GEMINI_API_KEY') else 'sem chave Gemini'),
local=dict(nome=PROVEDORES['local'], pronto=bool(local['url']), detalhe=local['modelo'] or local['url'],
motivo='' if local['url'] else 'configure o endereço', url=local['url'], modelo=local['modelo'],
chave=bool(local['chave'])),
s10=dict(nome=PROVEDORES['s10'], pronto=bool(s10), detalhe='Qwen3 0.6B (fraco para criar)',
motivo='' if s10 else 'indisponível'))
def chamar(self, ia, prompt, schema):
if ia == 'gemini':
return gemini.gerar_json(dict(contents=[dict(role='user', parts=[dict(text=prompt)])], generationConfig=dict(
temperature=0.8, maxOutputTokens=4096, responseMimeType='application/json', responseSchema=schema)))
if ia == 'local':
url, modelo, chave = self.config['local']['url'].rstrip('/'), self.config['local']['modelo'], self.config['local']['chave']
if not url:
raise ValueError('Configure o endereço da IA local')
cab = {'Content-Type': 'application/json'}
if chave:
cab['Authorization'] = 'Bearer ' + chave
extra = {}
else:
url, cab = self.s10()
if not url:
raise ValueError('Qwen do S10 indisponível')
modelo, extra = 'local', dict(chat_template_kwargs=dict(enable_thinking=False))
prompt += ' /no_think'
payload = dict(model=modelo or 'local', temperature=0.8, max_tokens=3000, messages=[
dict(role='system', content='Responda somente com JSON válido no formato pedido.'),
dict(role='user', content=prompt)],
response_format=dict(type='json_schema', json_schema=dict(name='receita', strict=True, schema=schema)), **extra)
if url.endswith('/v1'):
url = url[:-3]
req = Request(url + '/v1/chat/completions', data=json.dumps(payload).encode(), headers=cab)
try:
with urlopen(req, timeout=900) as resp: # a IA local pode levar minutos
dados = json.load(resp)
except HTTPError as e:
raise ValueError(f'A IA local recusou o pedido (HTTP {e.code})') from None
except (URLError, TimeoutError, OSError):
raise ValueError('Não consegui falar com a IA local: confira se ela está ligada e o endereço') from None
return _extrair_json(dados['choices'][0]['message']['content'])
# ------------------------------------------------------------ criar
def base(self, tipo, nome):
if not nome:
return None
if str(nome).startswith('criado:'):
item = self.item(int(str(nome)[7:]))
return item['receita'] if item and item['tipo'] == tipo else None
return (EXPRESSOES_BASE if tipo == 'expressao' else ATIVIDADES_BASE).get(nome)
def criar(self, tipo, pedido, ia, base=None):
if tipo not in TIPOS:
raise ValueError('Tipo inválido')
if ia not in PROVEDORES:
raise ValueError('IA inválida')
if not isinstance(pedido, str) or not pedido.strip() or len(pedido) > 600:
raise ValueError('Descreva o pedido em até 600 caracteres')
if not self.provedores()[ia]['pronto']:
raise ValueError(PROVEDORES[ia] + ': ' + self.provedores()[ia]['motivo'])
with self.lock:
if self.ocupado:
raise ValueError('Já tem uma criação em andamento')
self.ocupado = dict(tipo=tipo, ia=ia, desde=time.time(), pedido=pedido.strip())
self.erro = ''
receita_base = self.base(tipo, base) if tipo != 'figurinha' else None
threading.Thread(target=self._criar, args=(tipo, pedido.strip(), ia, receita_base), daemon=True).start()
self.avisar()
return self.status()
def _criar(self, tipo, pedido, ia, base):
try:
schema = dict(expressao=SCHEMA_EXPRESSAO, atividade=SCHEMA_ATIVIDADE, figurinha=SCHEMA_FIGURINHA)[tipo]
bruto = self.chamar(ia, montar_prompt(tipo, pedido, base), schema)
if tipo == 'expressao':
receita = validar_expressao(bruto)
elif tipo == 'atividade':
receita = validar_atividade(bruto)
else:
emoji = _emoji(bruto.pop('emoji', '')) if isinstance(bruto, dict) else '🎨'
desenho = validar_desenho(bruto)
receita = dict(nome=desenho['nome'][:30], emoji=emoji, desenho=desenho)
with self.lock, self.db() as db:
ident = db.execute('INSERT INTO criacoes(horario, tipo, nome, emoji, receita, ia, pedido) VALUES (?,?,?,?,?,?,?)',
(time.time(), tipo, receita['nome'], receita['emoji'],
json.dumps(receita, ensure_ascii=False), ia, pedido)).lastrowid
db.execute('DELETE FROM criacoes WHERE id NOT IN (SELECT id FROM criacoes ORDER BY id DESC LIMIT ?)', (MAX_CRIACOES,))
self.tocar(ident) # mostra na hora no celular
except Exception as e:
with self.lock:
self.erro = str(e)[:200]
finally:
with self.lock:
self.ocupado = None
self.avisar()
# ------------------------------------------------------------ biblioteca
def item(self, ident):
with self.db() as db:
row = db.execute('SELECT id, horario, tipo, nome, emoji, receita, ia, pedido FROM criacoes WHERE id=? AND excluida=0',
(ident,)).fetchone()
if not row:
return None
return dict(id=row[0], horario=row[1], tipo=row[2], nome=row[3], emoji=row[4], receita=json.loads(row[5]),
ia=row[6], pedido=row[7])
def lista(self):
with self.db() as db:
rows = db.execute('SELECT id FROM criacoes WHERE excluida=0 ORDER BY id DESC LIMIT ?', (MAX_CRIACOES,)).fetchall()
return [self.item(r[0]) for r in rows]
def tocar(self, ident):
item = self.item(ident) if type(ident) is int else None
if not item:
raise ValueError('Criação não encontrada')
r = item['receita']
if item['tipo'] == 'expressao':
self.enfileirar('expressao_criada ' + json.dumps(r, ensure_ascii=False, separators=(',', ':')))
elif item['tipo'] == 'atividade':
self.enfileirar('atividade_criada ' + json.dumps(r, ensure_ascii=False, separators=(',', ':')))
else:
from treinamento import validar_receita
if self.treinamento is None:
raise ValueError('Biblioteca de reações indisponível')
passo = dict(olhos='aberto', boca='sorriso', cor='#5cd6ff', dx=0, dy=0, escala=1, duracao=2)
receita = validar_receita(dict(nome=r['nome'], motivo='Criada no estúdio', simbolo='nenhum', legenda='',
passos=[passo, dict(passo, escala=1.05)], figurinha=r['desenho']))
with self.treinamento.lock:
self.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=receita)
return dict(ok=True)
def apagar(self, ident):
if type(ident) is not int:
raise ValueError('ID inválido')
with self.db() as db:
db.execute('UPDATE criacoes SET excluida=1 WHERE id=?', (ident,))
self.avisar()
return dict(ok=True)
def configurar(self, dados):
local = dados.get('local')
if not isinstance(local, dict):
raise ValueError('Esperava a configuração da IA local')
novo = dict(self.config['local'])
for k in ('url', 'modelo', 'chave'):
if k in local:
v = local[k]
if not isinstance(v, str) or len(v) > 300:
raise ValueError(k + ' inválido')
if k == 'url' and v and not re.fullmatch(r'https?://[\w.\-:]+(/v1)?/?', v.strip()):
raise ValueError('Endereço deve ser como http://192.168.1.50:1234')
novo[k] = v.strip()
with self.lock:
self.config['local'] = novo
gravar_atomico(self.caminho_config, self.config, privado=True)
self.avisar()
return self.status()
def testar(self, ia):
if ia == 'gemini':
if not os.environ.get('GEMINI_API_KEY'):
raise ValueError('Sem chave Gemini (Ajustes › Inteligência artificial)')
return dict(ok=True, mensagem='Gemini configurado: ' + gemini.MODELO)
url = self.config['local']['url'].rstrip('/') if ia == 'local' else (self.s10()[0] or '')
if not url:
raise ValueError('Configure o endereço primeiro')
if url.endswith('/v1'):
url = url[:-3]
cab = {}
if ia == 'local' and self.config['local']['chave']:
cab['Authorization'] = 'Bearer ' + self.config['local']['chave']
inicio = time.monotonic()
try:
with urlopen(Request(url + '/v1/models', headers=cab), timeout=8) as resp:
modelos = [m.get('id') for m in json.load(resp).get('data', [])]
except (URLError, HTTPError, OSError, ValueError):
raise ValueError('Não respondeu em ' + url) from None
return dict(ok=True, mensagem='Respondeu em %.1f s · modelos: %s' % (time.monotonic() - inicio, ', '.join(map(str, modelos[:5])) or '—'))
def status(self):
with self.lock:
ocupado = dict(self.ocupado) if self.ocupado else None
return dict(ocupado=ocupado, erro=self.erro, provedores=self.provedores(), criacoes=self.lista(),
bases=dict(expressao=[dict(id=k, nome=v['nome'], emoji=v['emoji']) for k, v in EXPRESSOES_BASE.items()],
atividade=[dict(id=k, nome=v['nome'], emoji=v['emoji']) for k, v in ATIVIDADES_BASE.items()]))
def avisar(self):
self.publicar('criacao', self.status())
# ------------------------------------------------------------ HTTP
def rotas(self, h, metodo):
rota = h.path.split('?', 1)[0]
if not rota.startswith('/criacao/'):
return False
try:
if metodo == 'GET' and rota == '/criacao/status':
resultado = self.status()
elif metodo == 'POST':
d = ler_json(h, 4096)
if rota == '/criacao/criar':
resultado = self.criar(d.get('tipo'), d.get('pedido'), d.get('ia'), d.get('base') or None)
elif rota == '/criacao/tocar':
resultado = self.tocar(d.get('id'))
elif rota == '/criacao/apagar':
resultado = self.apagar(d.get('id'))
elif rota == '/criacao/config':
resultado = self.configurar(d)
elif rota == '/criacao/testar':
resultado = self.testar(d.get('ia'))
else:
raise ValueError('Rota inválida')
else:
raise ValueError('Método inválido')
h.enviar(json.dumps(resultado, ensure_ascii=False))
except (ValueError, TypeError) as e:
h.enviar(json.dumps(dict(erro=str(e)), ensure_ascii=False), codigo=400)
return True
+3 -2
View File
@@ -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',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.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'
)
@@ -92,7 +92,8 @@ systemctl restart carinha-pc
echo "backup em `$backup"
"@
Write-Host '== Instalando na placa'
$script -replace "`r", '' | & ssh $alvo 'bash -s'
# O PowerShell 5.1 põe BOM no stdin do ssh; sem tirar, o bash lê "<BOM>set -e" e não para no primeiro erro.
$script -replace "`r", '' | & ssh $alvo "sed '1s/^\xEF\xBB\xBF//' | bash -s"
if ($LASTEXITCODE) { throw 'instalação na placa falhou (o backup foi feito antes de qualquer remoção)' }
Write-Host '== Conferindo'
+33 -12
View File
@@ -48,7 +48,8 @@ class Historico:
pass
self.ultimo = {} # tipo -> horário do último evento (intervalo mínimo)
self.pedido = None # (id do evento, monotonic) aguardando foto do app
self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None)
self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None,
ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma)
self.processo_pir = None
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)')
@@ -119,13 +120,30 @@ class Historico:
if not (self.config['presenca'] and self.config['pir'] and self.pir['disponivel']):
return False
with self.lock:
if self.pir['ativo']: # sensor ainda segurando: a pessoa continua ali
self.presenca['ultimo'] = agora
return False
if self.presenca['estado'] != 'presente' or agora - self.presenca['ultimo'] < self.config['ausencia'] * 60:
return False
self.presenca.update(estado='ausente', desde=agora)
self.ao_ausencia()
self.publicar('pir', dict(ultimo=self.pir['ultimo'], presenca=dict(self.presenca)))
self.avisar_pir()
return True
def avisar_pir(self):
self.publicar('pir', dict(ultimo=self.pir['ultimo'], ativo=self.pir['ativo'], presenca=dict(self.presenca)))
def nivel_pir(self, alto, agora=None, registrar=True):
"""Mudança no pino do PIR. Subida = presença (evento + foto); enquanto alto, a bolinha fica acesa."""
agora = agora if agora is not None else time.time()
self.pir['ativo'] = alto
if alto:
self.pir['ultimo'] = agora
self.marcar_presenca(agora)
self.avisar_pir()
if alto and registrar:
self.registrar('pir', 'presença')
def vigiar_ausencia(self):
while True:
time.sleep(10)
@@ -240,7 +258,7 @@ class Historico:
# ------------------------------------------------------------------ PIR
def vigiar_pir(self):
"""Mantém um `gpiomon` rodando enquanto o PIR estiver ligado; cada borda de subida = presença."""
"""Mantém um `gpiomon` rodando enquanto o PIR estiver ligado e acompanha o nível do pino."""
while True:
if not self.config['pir'] or os.name != 'posix' or not shutil.which('gpiomon'):
self.pir.update(disponivel=False, erro='' if self.config['pir'] else 'desligado')
@@ -251,25 +269,28 @@ class Historico:
chip, _, linha = self.pir['linha'].partition(':')
try:
# pull-down: com o sensor desligado o pino não fica "flutuando" (disparos falsos)
self.processo_pir = subprocess.Popen(['gpiomon', '-c', chip, '-b', 'pull-down', '-e', 'rising', linha],
lido = subprocess.run(['gpioget', '-c', chip, '-b', 'pull-down', '--numeric', linha],
capture_output=True, text=True, timeout=5)
self.processo_pir = subprocess.Popen(['gpiomon', '-c', chip, '-b', 'pull-down', '-e', 'both', '-F', '%E', linha],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self.pir.update(disponivel=True, erro='')
# nível de partida: alguém já na frente não gera borda de subida, mas acende a bolinha
self.nivel_pir(lido.stdout.strip() == '1', registrar=False)
inicio = time.monotonic()
for _ in self.processo_pir.stdout:
for linha_evento in self.processo_pir.stdout:
if not self.config['pir']:
break
if time.monotonic() - inicio < 2: # o pino "assenta" ao ligar o pull-down
continue
self.pir['ultimo'] = time.time()
self.marcar_presenca(self.pir['ultimo'])
self.publicar('pir', dict(ultimo=self.pir['ultimo'], presenca=dict(self.presenca))) # todo disparo, ao vivo
self.registrar('pir', 'presença')
alto = linha_evento.strip() == 'rising'
# o pino "assenta" ao ligar o pull-down: acompanha o nível, mas não registra evento
self.nivel_pir(alto, registrar=time.monotonic() - inicio >= 2)
erro = (self.processo_pir.stderr.read() or '').strip()[:200]
except OSError as e:
erro = str(e)
except subprocess.TimeoutExpired:
erro = 'gpioget não respondeu'
finally:
self.parar_pir()
self.pir.update(disponivel=False, erro=erro if self.config['pir'] else 'desligado')
self.pir.update(disponivel=False, ativo=False, erro=erro if self.config['pir'] else 'desligado')
time.sleep(3)
def parar_pir(self):
+63 -11
View File
@@ -32,7 +32,7 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote
from pathlib import Path
from comum import autorizado, carregar_token, gravar_atomico, ler_json
from controle_pc import ControlePC, versao
from controle_pc import ControlePC, madrugada, versao
from eventos import Eventos
from servos import ControleServos
from braco import Braco
@@ -41,6 +41,8 @@ from conversa import Conversa
from treinamento import Treinamento
from api_config import ConfigAPI
from historico import Historico
from vida import Vida
from criacao import Criacao
TRAVA = threading.Lock()
@@ -53,7 +55,11 @@ FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"]
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
"dormir", "acordar", "aleatorio", "parar",
"esconder", "espiar", "acenar", "pensar", "musica"]
"esconder", "espiar", "acenar", "pensar", "musica",
"rir", "bocejar", "confuso", "tonto", "timido", "comemorar"]
# Atividades (momentos dele): o bonequinho faz algo embaixo dos olhos (rosto-motor.js).
ATIVIDADES = ["ler", "cantar", "bola", "videogame", "pintar", "malhar", "dormir", "cafe", "cozinhar", "regar",
"violao", "dancar", "corda", "meditar", "telescopio", "pipa", "bolhas", "skate", "computador", "pescar"]
# Ordens internas que o rosto entende mas não viram botão.
ORDENS_INTERNAS = {"calma"}
@@ -70,7 +76,7 @@ ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador
# 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/",
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/criacao/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -186,6 +192,11 @@ def ordem_de_comando(tipo, valor):
if nome not in GESTOS and nome not in ORDENS_INTERNAS:
raise ValueError("Gesto desconhecido: " + nome)
return nome
if tipo == "atividade":
nome = str(valor or "").strip().lower()
if nome not in ATIVIDADES and nome != "parar":
raise ValueError("Atividade desconhecida: " + nome)
return "atividade " + nome
if tipo == "rosto":
nome = str(valor or "").strip().lower()
if nome not in ROSTOS:
@@ -237,7 +248,7 @@ class Handler(SimpleHTTPRequestHandler):
def modulos(self):
s = self.server
return [getattr(s, nome) for nome in ("api_config", "historico", "treinamento", "conversa", "cerebro",
return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "criacao", "treinamento", "conversa", "cerebro",
"braco", "servos", "controle") if hasattr(s, nome)]
def estado(self):
@@ -246,7 +257,8 @@ class Handler(SimpleHTTPRequestHandler):
with TRAVA:
aparencia = dict(APARENCIA)
dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)),
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS, formatos=FORMATOS)
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS, formatos=FORMATOS,
atividades=ATIVIDADES)
if hasattr(s, "controle"):
dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy())
if hasattr(s, "servos"):
@@ -262,6 +274,8 @@ class Handler(SimpleHTTPRequestHandler):
dados["api"] = s.api_config.status()
if hasattr(s, "historico"):
dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca))
if hasattr(s, "vida"):
dados["vida"] = s.vida.status()
return dados
def do_GET(self):
@@ -454,7 +468,7 @@ class Servidor(ThreadingHTTPServer):
def ligar_eventos(servidor):
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico"):
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao"):
if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar
@@ -479,15 +493,52 @@ def main():
servidor.cerebro.chave = servidor.token
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
servidor.historico = Historico(Path.cwd())
servidor.vida = vida = Vida(Path.cwd() / 'vida.json')
# Estúdio de criação: expressões, atividades e figurinhas por IA (Gemini, IA local ou Qwen do S10)
servidor.criacao = Criacao(Path.cwd() / 'criacoes.db', Path.cwd() / 'criacao.json')
servidor.criacao.enfileirar = enfileirar
servidor.criacao.treinamento = servidor.treinamento
servidor.criacao.s10 = lambda: (servidor.cerebro.endpoint, servidor.cerebro.cabecalhos())
def registrar_conversa(papel, texto):
servidor.treinamento.registrar(papel, texto)
if papel == 'user':
vida.estimulo('conversa') # alguém conversou com ele: companhia
servidor.conversa.memoria.on_registrar = registrar_conversa
def celular_dormindo():
resumo = servidor.controle.resumo()
return bool(resumo['estado'].get('dormindo')) if resumo['online'] else None
def alguem_perto():
h = servidor.historico
recente = max(h.pir.get('ultimo') or 0, h.ultimo.get('movimento', 0))
return bool(h.pir.get('ativo')) or time.time() - recente < 120
vida.enfileirar = enfileirar
vida.treinamento = servidor.treinamento
vida.celular_dormindo = celular_dormindo
vida.celular_online = lambda: servidor.controle.resumo()['online']
def celular_atividade():
resumo = servidor.controle.resumo()
return resumo['estado'].get('atividade') if resumo['online'] else None
vida.celular_atividade = celular_atividade
vida.alguem_perto = alguem_perto
vida.madrugada = lambda agora: madrugada(servidor.controle.config, agora)
servidor.cerebro.vida_ligada = lambda: vida.config['ligado']
# E-Stop parado, ou modo presença com ninguém em casa (dormindo em silêncio): não faz nada
vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente"
def presenca(tipo):
# PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda
if tipo == "pir":
vida.estimulo(tipo)
# PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda.
# De madrugada não: alguém se mexendo na cama não é motivo para acordar.
if tipo == "pir" and not madrugada(servidor.controle.config):
with servidor.controle.lock:
dormindo = bool(servidor.controle.estado.get("dormindo"))
if dormindo:
if dormindo and not vida.sono_profundo and vida.pir_acorda():
enfileirar("acordar")
def camera(ligada):
@@ -496,7 +547,8 @@ def main():
def alguem_chegou():
camera(True) # modo presença: o PIR liga a câmera do celular
enfileirar("acordar")
if not madrugada(servidor.controle.config) and not vida.sono_profundo:
enfileirar("acordar")
def ninguem_por_perto():
camera(False) # sem ninguém: câmera desligada, dorme e fica em silêncio
+863 -2
View File
@@ -107,7 +107,20 @@
}
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes).
// Tonto: uma espiral girando no lugar de cada olho (cada um gira para um lado, como no emoji 😵‍💫).
function espiral(c, o, cx, cy, R, lado) {
const voltas = 2.75, giro = (o.t || 0) * 7 * lado;
c.save(); c.strokeStyle = css(o.cor); c.lineWidth = Math.max(2, R * 0.17); c.lineCap = 'round';
c.beginPath();
for (let th = 0; th <= voltas * Math.PI * 2; th += 0.22) {
const rr = R * th / (voltas * Math.PI * 2), a = th * lado + giro;
if (th) c.lineTo(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr); else c.moveTo(cx, cy);
}
c.stroke(); c.restore();
}
function olhoPlano(c, o, p, est, e) {
if (o.espiral) { espiral(c, o, p.cx, p.cy, Math.min(est.w, est.h) * p.ry * 0.5, p.lado || 1); return; }
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 };
@@ -162,6 +175,7 @@
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) {
if (o.espiral) { espiral(c, o, p.cx, p.cy, est.w * p.ry * 0.5, p.lado || 1); return; }
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();
@@ -289,7 +303,7 @@
// ------------------------------------------------------------------ mãos
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências
// de rosto de mesmo nome no carinha.html.
const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 };
const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8, bocejar: 2.1 };
const maos = { modo: null, t0: null };
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); };
const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
@@ -387,6 +401,16 @@
const y = fora + (y0 - fora) * v;
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0;
mao(c, o, x, y, s * 0.95, -0.18 + balanco, -1, 0, 1, false, true);
} else if (maos.modo === 'bocejar') {
// mão na frente da boca, com tapinhas, e os Zzz subindo do olho
const ida = voltinha(k / 0.25), volta = faixaDe(k, 0.78, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
const bx = (e.cx + d.cx) / 2 + e.ry * 0.12, by = e.cy + e.ry * 1.2;
const tapa = k > 0.3 && k < 0.75 ? Math.abs(Math.sin((k - 0.3) * Math.PI * 6)) * s * 0.12 : 0;
mao(c, o, bx + e.ry * 0.5 * (1 - Math.min(1, v)), fora + (by + s * 0.55 - fora) * v + tapa, s * 0.9, -0.22, -1, 0.15, 0, false, true, true);
c.save(); c.globalAlpha *= Math.min(1, v);
desenharZzz(c, Object.assign({}, o, { dormindo: true }), olhos, 0.8);
c.restore();
} else if (maos.modo === 'pensar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
@@ -518,6 +542,838 @@
c.restore();
}
// ------------------------------------------------------------ atividades
// "Momentos dele": um bonequinho do Carinha (silhueta na cor dos olhos) faz algo na parte de
// baixo da tela enquanto os olhos sobem, encolhem e acompanham. Camada própria, atrás dos olhos,
// vale para qualquer rosto (SVG ou motor). O carinha.html pergunta para onde olhar
// (olharAtividade) e chama palco() a cada quadro.
const ATIVIDADES = {
ler: { nome: 'Lendo', emoji: '📖', dur: [25, 45] },
cantar: { nome: 'Cantando', emoji: '🎤', dur: [18, 30] },
bola: { nome: 'Jogando bola', emoji: '⚽', dur: [20, 35] },
videogame: { nome: 'Videogame', emoji: '🎮', dur: [25, 45] },
pintar: { nome: 'Pintando', emoji: '🎨', dur: [25, 40] },
malhar: { nome: 'Malhando', emoji: '🏋️', dur: [15, 25] },
dormir: { nome: 'Cochilando', emoji: '🛏️', dur: [40, 80] },
cafe: { nome: 'Tomando café', emoji: '☕', dur: [20, 35] },
cozinhar: { nome: 'Cozinhando', emoji: '🍳', dur: [20, 35] },
regar: { nome: 'Regando planta', emoji: '🪴', dur: [20, 30] },
violao: { nome: 'Tocando violão', emoji: '🎸', dur: [20, 35] },
dancar: { nome: 'Dançando', emoji: '💃', dur: [15, 30] },
corda: { nome: 'Pulando corda', emoji: '🪢', dur: [15, 25] },
meditar: { nome: 'Meditando', emoji: '🧘', dur: [25, 45] },
telescopio: { nome: 'Olhando estrelas', emoji: '🔭', dur: [25, 40] },
pipa: { nome: 'Soltando pipa', emoji: '🪁', dur: [20, 35] },
bolhas: { nome: 'Bolhas de sabão', emoji: '🫧', dur: [18, 30] },
skate: { nome: 'Andando de skate', emoji: '🛹', dur: [18, 30] },
computador: { nome: 'No computador', emoji: '💻', dur: [25, 45] },
pescar: { nome: 'Pescando', emoji: '🎣', dur: [30, 50] },
receita: { nome: 'Criada por IA', emoji: '✨', dur: [20, 40] }, // receita do estúdio de criação
};
const ativ = { nome: null, t0: 0, dur: 0, fim: 0, receita: null };
let palcoTela = null, pctx = null;
const agoraS = () => performance.now() / 1000;
function iniciarAtividade(nome, segundos, receita) {
if (!ATIVIDADES[nome] || (nome === 'receita' && !receita)) { pararAtividade(); return false; }
const [a, b] = ATIVIDADES[nome].dur;
ativ.nome = nome; ativ.t0 = agoraS(); ativ.fim = 0; ativ.receita = receita || null;
ativ.dur = segundos || a + Math.random() * (b - a);
return true;
}
function pararAtividade() { if (ativ.nome && !ativ.fim) ativ.fim = agoraS(); }
// 0..1: entra e sai em 0,6 s; termina sozinha depois da duração
function visibilidade(t) {
if (!ativ.nome) return 0;
if (!ativ.fim && t - ativ.t0 >= ativ.dur) ativ.fim = t;
const entra = Math.min(1, Math.max(0, (t - ativ.t0) / 0.6));
if (!ativ.fim) return entra;
const sai = 1 - (t - ativ.fim) / 0.6;
if (sai <= 0) { ativ.nome = null; return 0; }
return Math.min(entra, sai);
}
// movimentos de cada cena, em função do tempo k (s) desde o início
const bola = (k) => { const p = (k % 1.1) / 1.1; return { p, h: 4 * p * (1 - p), x: 0.35 * Math.sin(k * 0.7) }; };
const pincel = (k) => ({ x: Math.sin(k * 3.1) * 0.6 + Math.sin(k * 1.3) * 0.4, y: Math.cos(k * 2.3) * 0.7 });
const forca = (k) => (1 - Math.cos(k * 2.2)) / 2; // 0 = halteres no ombro, 1 = braços em cima
/** Para onde os olhos grandes olham (unidades do rosto: dx ±24, dy ±12) e que cara fazem. */
function olharAtividade(t) {
const v = visibilidade(t);
if (!v) return null;
const k = t - ativ.t0, info = { nome: ativ.nome, v, x: 0, y: 0, saindo: !!ativ.fim };
const lado = innerWidth >= innerHeight * 1.2 ? 1 : 0; // deitado: a cena fica à direita
if (ativ.nome === 'ler') { // lê linha a linha: varre da esquerda para a direita
const linha = (k % 2.4) / 2.4;
info.x = (linha < 0.9 ? -8 + 16 * (linha / 0.9) : 8 - 16 * ((linha - 0.9) / 0.1)) + lado * 13;
info.y = 7 + (Math.floor(k / 2.4) % 3) * 1.5;
} else if (ativ.nome === 'cantar') {
info.x = 6 * Math.sin(k * 1.3) + lado * 12; info.y = -4 + 4 * Math.sin(k * 4);
info.forma = 'feliz'; info.boca = Math.sin(k * 7) > -0.2 ? 'aberta' : 'sorriso';
} else if (ativ.nome === 'bola') { // segue a bola
const b = bola(k); info.x = b.x * 16 + lado * 14; info.y = 9 - b.h * 18;
} else if (ativ.nome === 'videogame') { // vidrado na TV; de vez em quando comemora
info.x = 18 + lado * 5 + Math.sin(k * 9) * 1.5; info.y = 1;
if (k % 9 > 7.6) { info.forma = 'feliz'; info.boca = 'sorriso'; }
} else if (ativ.nome === 'pintar') {
const p = pincel(k); info.x = 16 + lado * 5 + p.x * 4; info.y = -1 + p.y * 5;
} else if (ativ.nome === 'malhar') {
const f = forca(k); info.x = lado * 14; info.y = 6 - f * 12; info.ab = f > 0.85 ? 0.55 : 1;
} else if (ativ.nome === 'receita') {
const R = ativ.receita || {}, w = Math.PI * 2 * (R.ritmo || 0.8);
const o = (R.objetos || [])[0];
if (R.olhar === 'cima') { info.x = lado * 12; info.y = -10; }
else if (R.olhar === 'baixo') { info.x = lado * 10; info.y = 8; }
else if (R.olhar === 'lado') { info.x = lado * 20; info.y = 2; }
else if (R.olhar === 'frente') { info.x = 4 * Math.sin(k * 0.7); info.y = 0; }
else { // segue o primeiro objeto
info.x = lado * 14 + Math.sin(k * w) * 5;
info.y = o && o.movimento === 'pular' ? 8 - Math.abs(Math.sin(k * w * 0.5)) * 16 : o && o.movimento === 'subir' ? -6 : 3;
}
if (R.forma && R.forma !== 'aberto') info.forma = R.forma;
if (R.boca && R.boca !== 'neutra') info.boca = R.boca;
} else if (ativ.nome === 'dormir') {
info.forma = 'dormir'; info.boca = 'sono'; info.x = lado * 6; info.y = 4;
} else if (ativ.nome === 'cafe') {
const f = (k % 5) / 5, gole = f > 0.55 && f < 0.92;
info.x = lado * 12; info.y = gole ? -3 : 3; if (gole) info.forma = 'feliz';
} else if (ativ.nome === 'cozinhar') {
const cc = (k % 2.6) / 2.6, h = cc < 0.55 ? 4 * (cc / 0.55) * (1 - cc / 0.55) : 0;
info.x = lado * 16; info.y = 4 - h * 14;
} else if (ativ.nome === 'regar') {
info.x = lado * 18; info.y = 8;
} else if (ativ.nome === 'violao') {
info.x = lado * 8 + 3 * Math.sin(k * 1.1); info.y = 6 + 1.5 * Math.sin(k * 2.2); info.boca = 'sorriso';
if (Math.sin(k * 0.7) > 0.3) info.forma = 'feliz';
} else if (ativ.nome === 'dancar') {
info.x = lado * 10 + 6 * Math.sin(k * 5); info.y = -2 + 4 * Math.abs(Math.sin(k * 5)); info.forma = 'feliz'; info.boca = 'sorriso';
} else if (ativ.nome === 'corda') {
const th = k * Math.PI * 2 * 0.85; info.x = lado * 12; info.y = 4 - Math.pow(Math.max(0, Math.cos(th)), 0.6) * 10;
} else if (ativ.nome === 'meditar') {
info.forma = 'dormir'; info.boca = 'sorriso'; info.x = lado * 6; info.y = -2 + 2 * Math.sin(k * 1.2);
} else if (ativ.nome === 'telescopio') {
info.x = lado * 16; info.y = -11;
} else if (ativ.nome === 'pipa') {
info.x = lado * 14 + 4 * Math.sin(k * 0.8); info.y = -11 + 2 * Math.cos(k * 1.1);
} else if (ativ.nome === 'bolhas') {
info.x = lado * 14 + 4 * Math.sin(k * 0.9); info.y = -8 + 3 * Math.sin(k * 0.6);
} else if (ativ.nome === 'skate') {
info.x = lado * 12 + 7 * Math.sin(k * 0.9); info.y = 6;
} else if (ativ.nome === 'computador') {
info.x = lado * 18 + Math.sin(k * 20) * 0.6; info.y = 2 + ((k * 2.5) % 1) * 1.5;
} else if (ativ.nome === 'pescar') {
const cc = k % 8; info.x = lado * 18; info.y = 7;
if (cc > 6.2 && cc < 7.6) { info.forma = 'feliz'; info.boca = 'sorriso'; info.y = 0; }
}
return info;
}
// --- o bonequinho: cabeça-tela com olhinhos, antena, tronco, braços e pernas articulados.
// Ângulos em radianos: 0 = apontando para baixo; positivo gira para a direita da tela.
function membro(c, x0, y0, a1, a2, l1, l2, w) {
const x1 = x0 + Math.sin(a1) * l1, y1 = y0 + Math.cos(a1) * l1;
const x2 = x1 + Math.sin(a1 + a2) * l2, y2 = y1 + Math.cos(a1 + a2) * l2;
c.beginPath(); capsula(c, x0, y0, x1, y1, w / 2); c.fill(); c.stroke();
c.beginPath(); capsula(c, x1, y1, x2, y2, w / 2); c.fill(); c.stroke();
return { x: x2, y: y2 };
}
function boneco(c, x, y, s, p, cor, k) {
c.fillStyle = css(cor.map((v) => v * 0.22), 0.96); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.lineJoin = 'round'; c.lineCap = 'round';
const w = s * 0.27, braco = [s * 0.62, s * 0.58];
const perna = p.sentado ? [s * 0.38, s * 0.62] : [s * 0.72, s * 0.7];
const hx = x + (p.dx || 0) * s, hy = p.sentado ? y - perna[1] - s * 0.12 : y - (perna[0] + perna[1]) * 0.97;
const pE = p.pE || [-0.08, 0], pD = p.pD || [0.08, 0];
membro(c, hx - s * 0.25, hy, pE[0], pE[1], perna[0], perna[1], w * 1.1);
membro(c, hx + s * 0.25, hy, pD[0], pD[1], perna[0], perna[1], w * 1.1);
c.save();
c.translate(hx, hy); c.rotate(-(p.inclina || 0));
// tronco
caixa(c, -s * 0.5, -s * 1.12, s, s * 1.12, s * 0.25); c.fill(); c.stroke();
c.save(); c.fillStyle = css(cor, 0.85); // luzinha no peito
c.beginPath(); c.arc(0, -s * 0.62, s * 0.09 * (0.8 + 0.2 * Math.sin(k * 3)), 0, Math.PI * 2); c.fill(); c.restore();
// cabeça (uma telinha, como o celular) com olhinhos e antena
c.save(); c.translate(0, -s * 1.25); c.rotate(-(p.cabeca || 0));
c.beginPath(); c.moveTo(0, -s * 0.95); c.lineTo(0, -s * 1.25); c.stroke();
c.save(); c.fillStyle = css(cor); c.beginPath(); c.arc(0, -s * 1.3, s * 0.1, 0, Math.PI * 2); c.fill(); c.restore();
caixa(c, -s * 0.66, -s * 0.98, s * 1.32, s * 0.98, s * 0.28); c.fill(); c.stroke();
const ox = (p.olhoX || 0) * s * 0.12, oy = (p.olhoY || 0) * s * 0.1;
const pisca = (k % 3.7) < 0.12 ? 0.15 : 1;
c.save(); c.fillStyle = css(cor); c.strokeStyle = css(cor);
for (const lado of [-1, 1]) {
if (p.fechados) { c.lineWidth = Math.max(1.5, s * 0.07); c.beginPath(); c.arc(lado * s * 0.27, -s * 0.52, s * 0.12, 0.2, Math.PI - 0.2); c.stroke(); continue; }
c.beginPath(); c.ellipse(lado * s * 0.27 + ox, -s * 0.5 + oy, s * 0.11, s * 0.16 * pisca, 0, 0, Math.PI * 2); c.fill();
}
c.restore(); c.restore();
// braços (no mesmo quadro do tronco inclinado); as mãos voltam para segurar os objetos
const bE = p.bE || [-0.25, 0.15], bD = p.bD || [0.25, -0.15];
const mE = membro(c, -s * 0.58, -s * 0.98, bE[0], bE[1], braco[0], braco[1], w);
const mD = membro(c, s * 0.58, -s * 0.98, bD[0], bD[1], braco[0], braco[1], w);
c.restore();
const rot = (pt) => { // do quadro do tronco para a tela
const a = -(p.inclina || 0), cs = Math.cos(a), sn = Math.sin(a);
return { x: hx + pt.x * cs - pt.y * sn, y: hy + pt.x * sn + pt.y * cs };
};
return { mE: rot(mE), mD: rot(mD), quadril: { x: hx, y: hy }, cabeca: rot({ x: 0, y: -s * 1.75 }) };
}
const claro = (cor, a) => css(cor.map((v) => v + (255 - v) * 0.55), a);
const escuro = (cor) => css(cor.map((v) => v * 0.25));
function nota(c, x, y, s) { // ♪
c.beginPath(); c.ellipse(x, y, s * 0.2, s * 0.15, -0.4, 0, Math.PI * 2); c.fill();
c.fillRect(x + s * 0.14, y - s * 0.7, s * 0.06, s * 0.7);
c.beginPath(); c.moveTo(x + s * 0.2, y - s * 0.7); c.quadraticCurveTo(x + s * 0.5, y - s * 0.55, x + s * 0.42, y - s * 0.3); c.stroke();
}
const ROSA_ = [255, 112, 176], AMARELO_ = [255, 218, 96], VERDE_ = [120, 255, 160], LARANJA_ = [255, 150, 70];
const SENTADO = { sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35] };
const rnd = (i, sem) => { const a = Math.sin(i * 12.9898 + sem * 78.233) * 43758.5453; return a - Math.floor(a); };
function estrela(c, x, y, r) {
c.beginPath();
for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5, rr = i % 2 ? r * 0.45 : r; c.lineTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr); }
c.closePath(); c.fill();
}
function vapor(c, x, y, s, k, cor) { // três fios subindo e ondulando
c.save(); c.strokeStyle = css(cor, 0.7); c.lineWidth = Math.max(1, s * 0.04);
const alfa = c.globalAlpha;
for (let i = 0; i < 3; i++) {
const f = (k * 0.5 + i / 3) % 1, bx = x + (i - 1) * s * 0.1, by = y - f * s * 0.7;
c.globalAlpha = alfa * (1 - f) * Math.min(1, f / 0.2);
c.beginPath(); c.moveTo(bx, by); c.bezierCurveTo(bx + s * 0.1, by - s * 0.12, bx - s * 0.1, by - s * 0.24, bx, by - s * 0.36); c.stroke();
}
c.restore();
}
function xicara(c, x, y, s, cor) {
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.moveTo(x - s * 0.18, y - s * 0.2); c.lineTo(x + s * 0.18, y - s * 0.2); c.lineTo(x + s * 0.14, y + s * 0.08); c.lineTo(x - s * 0.14, y + s * 0.08); c.closePath(); c.fill(); c.stroke();
c.beginPath(); c.arc(x + s * 0.2, y - s * 0.07, s * 0.08, -1.4, 1.4); c.stroke();
}
function chama(c, x, y, alt, s) {
c.fillStyle = css(LARANJA_);
c.beginPath(); c.moveTo(x, y - alt); c.quadraticCurveTo(x + s * 0.13, y - alt * 0.3, x, y); c.quadraticCurveTo(x - s * 0.13, y - alt * 0.3, x, y - alt); c.fill();
c.fillStyle = css(AMARELO_);
c.beginPath(); c.moveTo(x, y - alt * 0.6); c.quadraticCurveTo(x + s * 0.06, y - alt * 0.2, x, y); c.quadraticCurveTo(x - s * 0.06, y - alt * 0.2, x, y - alt * 0.6); c.fill();
}
function zzz(c, x, y, s, k, cor) { // Zs subindo
c.save(); c.strokeStyle = css(cor); c.lineCap = 'round'; c.lineJoin = 'round';
const alfa = c.globalAlpha;
for (let i = 0; i < 3; i++) {
const f = (k * 0.3 + i / 3) % 1, tam = s * (0.18 + 0.2 * f), zx = x - s * 0.5 * f, zy = y - s * 1.3 * f;
c.globalAlpha = alfa * Math.min(1, f / 0.15) * (1 - f); c.lineWidth = Math.max(1.5, tam * 0.2);
c.beginPath(); c.moveTo(zx - tam / 2, zy - tam / 2); c.lineTo(zx + tam / 2, zy - tam / 2); c.lineTo(zx - tam / 2, zy + tam / 2); c.lineTo(zx + tam / 2, zy + tam / 2); c.stroke();
}
c.restore();
}
function notas(c, x, y, s, k, cor, dir) {
c.fillStyle = css(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
const alfa = c.globalAlpha;
for (let i = 0; i < 3; i++) {
const f = (k * 0.45 + i / 3) % 1;
c.globalAlpha = alfa * Math.min(1, f / 0.15) * (1 - f);
nota(c, x + dir * s * (0.3 + 0.5 * i) + Math.sin(f * 6 + i) * s * 0.2, y - f * s * 1.6, s * 0.7);
}
c.globalAlpha = alfa;
}
// ---- objetos que as receitas podem usar (mesmos desenhos das cenas feitas à mão)
function desenharObjeto(c, tipo, ox, oy, s, k, cor, giro, j) {
c.save(); c.translate(ox, oy); if (giro) c.rotate(giro);
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
if (tipo === 'bola') {
const r = s * 0.28; c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3)); c.beginPath();
for (let i = 0; i < 5; i++) { const a = i * 1.2566; c.lineTo(Math.cos(a) * r * 0.38, Math.sin(a) * r * 0.38); }
c.closePath(); c.fill();
} else if (tipo === 'livro') {
const lw = s * 0.62, lh = s * 0.5;
for (const lado of [-1, 1]) { c.beginPath(); c.moveTo(0, lh * 0.55); c.lineTo(lado * lw, lh * 0.45); c.lineTo(lado * lw, -lh * 0.45); c.lineTo(0, -lh * 0.35); c.closePath(); c.fill(); c.stroke(); }
} else if (tipo === 'xicara') {
xicara(c, 0, 0, s, cor); vapor(c, 0, -s * 0.3, s, k, cor);
} else if (tipo === 'nota') {
c.fillStyle = css(cor); nota(c, 0, 0, s * 0.7);
} else if (tipo === 'estrela') {
c.fillStyle = css(j % 2 ? AMARELO_ : cor); estrela(c, 0, 0, s * 0.16);
} else if (tipo === 'coracao') {
c.fillStyle = css(ROSA_); coracao(c, 0, 0, s * 0.22);
} else if (tipo === 'zzz') {
zzz(c, 0, 0, s, k + j, cor);
} else if (tipo === 'chama') {
chama(c, 0, s * 0.15, s * (0.35 + 0.08 * Math.sin(k * 17 + j)), s);
} else if (tipo === 'bolha') {
const r = s * (0.13 + 0.1 * rnd(j, 4)); c.fillStyle = css(cor, 0.1);
c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.strokeStyle = claro(cor, 1); c.beginPath(); c.arc(0, 0, r * 0.65, -2.4, -1.6); c.stroke();
} else if (tipo === 'halter') {
c.fillRect(-s * 0.25, -s * 0.035, s * 0.5, s * 0.07);
for (const lado of [-1, 1]) { caixa(c, lado * s * 0.28 - s * 0.07, -s * 0.17, s * 0.14, s * 0.34, s * 0.04); c.fill(); c.stroke(); }
} else if (tipo === 'microfone') {
c.fillStyle = escuro(cor); c.beginPath(); capsula(c, 0, 0, -s * 0.1, -s * 0.4, s * 0.07); c.fill(); c.stroke();
c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(-s * 0.12, -s * 0.5, s * 0.14, 0, Math.PI * 2); c.fill(); c.stroke();
} else if (tipo === 'controle') {
caixa(c, -s * 0.42, -s * 0.16, s * 0.84, s * 0.34, s * 0.16); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3)); c.fillRect(-s * 0.3, -s * 0.02, s * 0.16, s * 0.05); c.fillRect(-s * 0.245, -s * 0.08, s * 0.05, s * 0.17);
} else if (tipo === 'pincel') {
c.beginPath(); c.moveTo(-s * 0.1, s * 0.08); c.lineTo(s * 0.4, -s * 0.12); c.stroke();
c.fillStyle = css([ROSA_, AMARELO_, VERDE_][Math.floor(k) % 3]); c.beginPath(); c.arc(s * 0.43, -s * 0.13, s * 0.07, 0, Math.PI * 2); c.fill();
} else if (tipo === 'flor') {
c.strokeStyle = css(VERDE_); c.beginPath(); c.moveTo(0, s * 0.5); c.lineTo(0, 0); c.stroke();
c.fillStyle = css(ROSA_);
for (let i = 0; i < 5; i++) { const a = i * 1.2566 + k * 0.3; c.beginPath(); c.arc(Math.cos(a) * s * 0.14, Math.sin(a) * s * 0.14, s * 0.1, 0, Math.PI * 2); c.fill(); }
c.fillStyle = css(AMARELO_); c.beginPath(); c.arc(0, 0, s * 0.08, 0, Math.PI * 2); c.fill();
}
c.restore();
}
/** Onde fica cada objeto da receita e como ele se mexe (pular, girar, subir, orbitar, piscar, balançar). */
function objetoReceita(c, o, i, b, x, y, s, k, cor, w) {
const q = Math.max(1, Math.min(5, o.quantidade || 1)), alfa = c.globalAlpha;
for (let j = 0; j < q; j++) {
let ox, oy;
if (o.onde === 'mao_direita') { ox = b.mD.x; oy = b.mD.y; }
else if (o.onde === 'mao_esquerda') { ox = b.mE.x; oy = b.mE.y; }
else if (o.onde === 'maos') { ox = (b.mE.x + b.mD.x) / 2; oy = (b.mE.y + b.mD.y) / 2; }
else if (o.onde === 'chao') { ox = x + s * (0.9 + i * 0.5); oy = y - s * 0.3; }
else if (o.onde === 'acima') { ox = b.cabeca.x + s * (0.8 + j * 0.5); oy = b.cabeca.y - s * 0.6; }
else { const a = (j / q) * Math.PI * 2 + i; ox = b.quadril.x + Math.cos(a) * s * 1.5; oy = b.quadril.y - s * 1.2 + Math.sin(a) * s * 1.1; }
if (q > 1 && (o.onde === 'mao_direita' || o.onde === 'mao_esquerda' || o.onde === 'maos' || o.onde === 'chao')) ox += (j - (q - 1) / 2) * s * 0.4;
let giro = 0;
c.globalAlpha = alfa;
if (o.movimento === 'pular') oy -= Math.abs(Math.sin(k * w * 0.5 + j)) * s * 1.6;
else if (o.movimento === 'girar') giro = k * 4 + j;
else if (o.movimento === 'balancar') ox += Math.sin(k * w + j) * s * 0.3;
else if (o.movimento === 'piscar') c.globalAlpha = alfa * (0.25 + 0.75 * Math.abs(Math.sin(k * 3 + j * 1.7)));
else if (o.movimento === 'subir') {
const f = (k * 0.35 + j / q) % 1;
ox += f * s * 0.8 + Math.sin(k * 2 + j) * s * 0.12; oy -= f * s * 1.8;
c.globalAlpha = alfa * Math.min(1, f / 0.12) * (1 - f);
} else if (o.movimento === 'orbitar') {
const a = k * 1.6 + (j / q) * Math.PI * 2;
ox = b.quadril.x + Math.cos(a) * s * 1.6; oy = b.quadril.y - s * 1.3 + Math.sin(a) * s * 0.9;
}
desenharObjeto(c, o.tipo, ox, oy, s, k, cor, giro, j);
}
c.globalAlpha = alfa;
}
const CENAS = {
// Atividade criada por IA: uma receita (criacao.py) montada com as peças que já existem aqui:
// o bonequinho, poses de braços/pernas e os objetos das outras cenas.
receita(c, x, y, s, k, cor) {
const R = ativ.receita || {};
const w = Math.PI * 2 * (R.ritmo || 0.8), onda = Math.sin(k * w);
const f1 = (onda + 1) / 2, f2 = R.alternados ? 1 - f1 : f1;
const rad = (g) => (g * Math.PI) / 180;
const ang = (b, f) => rad((b ? b.de : 20) + ((b ? b.ate : 40) - (b ? b.de : 20)) * f);
const pulo = (R.pulo || 0) * Math.max(0, onda), sobe = pulo * s * 0.5;
const balanco = Math.sin(k * w * 0.5) * (R.balanco || 0);
const cot = (R.cotovelos || 0) * 1.4;
let pE = [-0.08, 0], pD = [0.08, 0];
if (R.pernas === 'marchando') { pE = [-0.08 - 0.25 * f1, 0.45 * f1]; pD = [0.08 + 0.25 * (1 - f1), -0.45 * (1 - f1)]; }
else if (R.pernas === 'chutando') { const chute = Math.pow(Math.max(0, onda), 2); pD = [0.08 + chute * 0.9, -chute * 0.6]; }
else if (R.pernas === 'dancando') { pE = [-0.1 - f1 * 0.25, f1 * 0.4]; pD = [0.1 + (1 - f1) * 0.25, -(1 - f1) * 0.4]; }
else if (R.pernas === 'pulando') { pE = [-0.06, pulo * 0.3]; pD = [0.06, -pulo * 0.3]; }
const sentado = R.postura === 'sentado';
const b = boneco(c, x + balanco * s * 0.3, y - sobe, s, Object.assign(sentado ? Object.assign({}, SENTADO) : { pE, pD }, {
inclina: balanco * 0.25, cabeca: -balanco * 0.3, fechados: R.forma === 'dormir',
olhoY: R.olhar === 'cima' ? -1 : R.olhar === 'baixo' ? 0.8 : 0, olhoX: R.olhar === 'lado' ? 1 : 0,
bE: [-ang(R.braco_esquerdo, f1), cot], bD: [ang(R.braco_direito, f2), -cot],
}), cor, k);
(R.objetos || []).forEach((o, i) => objetoReceita(c, o, i, b, x, y, s, k, cor, w));
},
ler(c, x, y, s, k, cor) {
const b = boneco(c, x, y, s, {
sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35], cabeca: -0.08, olhoY: 0.8,
olhoX: -0.8 + 1.6 * ((k % 2.4) / 2.4), bE: [0.2, 2.4], bD: [-0.2, -2.4],
}, cor, k);
// livro aberto entre as mãos, com uma página virando de vez em quando
const cx = (b.mE.x + b.mD.x) / 2, cy = (b.mE.y + b.mD.y) / 2 + s * 0.14, lw = s * 0.72, lh = s * 0.55;
c.fillStyle = claro(cor, 0.95); c.strokeStyle = css(cor);
for (const lado of [-1, 1]) {
c.beginPath(); c.moveTo(cx, cy + lh * 0.1); c.lineTo(cx + lado * lw, cy); c.lineTo(cx + lado * lw, cy - lh);
c.lineTo(cx, cy - lh * 0.9); c.closePath(); c.fill(); c.stroke();
}
c.strokeStyle = css(cor, 0.5); c.lineWidth = Math.max(1, s * 0.03);
for (let i = 1; i <= 3; i++) for (const lado of [-1, 1]) {
const yy = cy - lh * (0.25 * i); c.beginPath(); c.moveTo(cx + lado * lw * 0.15, yy); c.lineTo(cx + lado * lw * 0.85, yy - lh * 0.03); c.stroke();
}
const virar = (k % 6) / 0.7; // vira em 0,7 s a cada 6 s
if (virar < 1) {
const px = cx + lw * Math.cos(virar * Math.PI), sobe = Math.sin(virar * Math.PI) * lh * 0.25;
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.moveTo(cx, cy + lh * 0.1); c.lineTo(px, cy - sobe); c.lineTo(px, cy - lh - sobe);
c.lineTo(cx, cy - lh * 0.9); c.closePath(); c.fill(); c.stroke();
}
},
cantar(c, x, y, s, k, cor) {
const balanco = Math.sin(k * 2.2);
const b = boneco(c, x, y, s, {
dx: balanco * 0.12, inclina: balanco * 0.08, cabeca: balanco * 0.1, olhoY: -0.3,
pE: [-0.1 - Math.max(0, balanco) * 0.15, 0], pD: [0.1 - Math.min(0, balanco) * 0.15, 0],
bD: [2.45, 0.9], bE: [-0.9 - 0.4 * Math.sin(k * 4.4), -0.4],
}, cor, k);
// microfone na mão direita, perto da boca
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); capsula(c, b.mD.x, b.mD.y, b.mD.x - s * 0.1, b.mD.y - s * 0.4, s * 0.07); c.fill(); c.stroke();
c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(b.mD.x - s * 0.12, b.mD.y - s * 0.5, s * 0.14, 0, Math.PI * 2); c.fill(); c.stroke();
// notas subindo
c.fillStyle = css(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
const alfa = c.globalAlpha;
for (let i = 0; i < 3; i++) {
const f = (k * 0.45 + i / 3) % 1;
c.globalAlpha = alfa * Math.min(1, f / 0.15) * (1 - f);
nota(c, b.cabeca.x + s * (0.9 + 0.5 * i) + Math.sin(f * 6 + i) * s * 0.2, b.cabeca.y + s * 0.4 - f * s * 1.6, s * 0.75);
}
c.globalAlpha = alfa;
},
bola(c, x, y, s, k, cor) {
const bl = bola(k), chute = Math.max(0, 1 - bl.p / 0.18) + Math.max(0, (bl.p - 0.9) / 0.1);
const b = boneco(c, x, y, s, {
dx: bl.x * 0.5, olhoX: bl.x * 2, olhoY: 0.6 - bl.h * 1.4,
pD: [0.08 + chute * 0.9, -chute * 0.6], pE: [-0.08, 0], bE: [-0.6, 0.3], bD: [0.6, -0.3],
}, cor, k);
const bx = b.quadril.x + s * 0.55 + bl.x * s * 0.3, by = y - s * 0.55 - bl.h * s * 2.6, r = s * 0.3;
const alfa = c.globalAlpha;
c.save(); c.globalAlpha = alfa * 0.35 * (1 - bl.h); c.fillStyle = css(cor); // sombra no chão
c.beginPath(); c.ellipse(bx, y, r * (1.1 - bl.h * 0.5), r * 0.18, 0, 0, Math.PI * 2); c.fill(); c.restore();
c.save(); c.translate(bx, by); c.rotate(k * 5);
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3)); // gomo da bola
c.beginPath(); for (let i = 0; i < 5; i++) { const a = i * 1.2566; c.lineTo(Math.cos(a) * r * 0.38, Math.sin(a) * r * 0.38); } c.closePath(); c.fill();
c.restore();
},
videogame(c, x, y, s, k, cor) {
const aperta = Math.sin(k * 23) * 0.05;
const b = boneco(c, x - s * 1.3, y, s, {
sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35], olhoX: 1, olhoY: 0.1, cabeca: -0.05,
bE: [0.15 + aperta, 0.95], bD: [-0.15 - aperta, -0.95],
}, cor, k);
// controle
const cx = (b.mE.x + b.mD.x) / 2, cy = (b.mE.y + b.mD.y) / 2;
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
caixa(c, cx - s * 0.42, cy - s * 0.16, s * 0.84, s * 0.34, s * 0.16); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3));
c.fillRect(cx - s * 0.3, cy - s * 0.02, s * 0.16, s * 0.05); c.fillRect(cx - s * 0.245, cy - s * 0.08, s * 0.05, s * 0.17);
for (const [dx, dy] of [[0.22, -0.05], [0.3, 0.04]]) { c.beginPath(); c.arc(cx + dx * s, cy + dy * s, s * 0.04, 0, Math.PI * 2); c.fill(); }
// TV com um joguinho: bonequinho pulando obstáculos
const tx = x + s * 1.15, tw = s * 2.1, th = s * 1.45, ty = y - s * 0.55 - th;
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(tx + tw / 2, ty + th); c.lineTo(tx + tw / 2, y); c.moveTo(tx + tw * 0.3, y); c.lineTo(tx + tw * 0.7, y); c.stroke();
caixa(c, tx, ty, tw, th, s * 0.12); c.fill(); c.stroke();
c.save(); caixa(c, tx + s * 0.1, ty + s * 0.1, tw - s * 0.2, th - s * 0.2, s * 0.06); c.clip();
const chao = ty + th - s * 0.32, px = s * 0.11;
c.fillStyle = css(cor, 0.6); c.fillRect(tx, chao, tw, s * 0.05);
const obst = tx + tw - ((k * 1.4) % 1) * (tw + s * 0.4);
c.fillStyle = css(cor); c.fillRect(obst, chao - px * 2, px * 1.4, px * 2);
const ox = tx + tw * 0.3, perto = (ox + px * 2 - obst) / (s * 0.9) + 0.5;
const pulo = perto > 0 && perto < 1 ? Math.sin(perto * Math.PI) : 0;
c.fillStyle = claro(cor, 1); c.fillRect(ox, chao - px * 2.4 - pulo * s * 0.45, px * 1.6, px * 2.4);
if (k % 9 > 7.6) { // vitória: estrelinhas na tela
c.fillStyle = claro(cor, 1); c.font = Math.round(s * 0.4) + 'px sans-serif'; c.textAlign = 'center';
c.fillText('★ ★ ★', tx + tw / 2, ty + th * 0.4);
}
c.restore();
},
pintar(c, x, y, s, k, cor) {
const pc = pincel(k);
const ex = x + s * 0.9, ew = s * 1.6, eh = s * 1.35, ey = y - s * 1.0 - eh;
// cavalete
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(ex + ew * 0.2, y); c.lineTo(ex + ew * 0.5, ey - s * 0.15); c.lineTo(ex + ew * 0.8, y); c.stroke();
c.fillStyle = claro(cor, 0.95); caixa(c, ex, ey, ew, eh, s * 0.05); c.fill(); c.stroke();
// pinceladas que vão aparecendo (sempre as mesmas para a mesma pintura)
const tintas = [cor, [255, 112, 176], [255, 218, 96], [120, 255, 160]];
const n = Math.min(40, Math.floor(k / 0.7));
c.save(); caixa(c, ex, ey, ew, eh, s * 0.05); c.clip();
for (let i = 0; i < n; i++) {
const a = Math.sin(i * 12.9898 + ativ.t0) * 43758.5453, rnd = a - Math.floor(a);
const b2 = Math.sin(i * 78.233 + ativ.t0) * 12345.678, rnd2 = b2 - Math.floor(b2);
c.fillStyle = css(tintas[i % 4], 0.9);
c.beginPath(); c.ellipse(ex + ew * (0.12 + rnd * 0.76), ey + eh * (0.12 + rnd2 * 0.76), s * 0.16, s * 0.07, rnd * 3, 0, Math.PI * 2); c.fill();
}
c.restore();
const b = boneco(c, x - s * 0.5, y, s, {
inclina: -0.06, olhoX: 1, olhoY: pc.y * 0.5, cabeca: -0.08,
bD: [1.75 + pc.y * 0.25, -0.35 + pc.x * 0.3], bE: [-0.7, 0.9], pE: [-0.12, 0], pD: [0.08, 0],
}, cor, k);
// pincel numa mão, paleta na outra
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); c.moveTo(b.mD.x - s * 0.1, b.mD.y + s * 0.08); c.lineTo(b.mD.x + s * 0.4, b.mD.y - s * 0.12); c.stroke();
c.fillStyle = css(tintas[Math.floor(k / 0.7) % 4]); c.beginPath(); c.arc(b.mD.x + s * 0.43, b.mD.y - s * 0.13, s * 0.07, 0, Math.PI * 2); c.fill();
c.fillStyle = claro(cor, 0.95); c.beginPath(); c.ellipse(b.mE.x, b.mE.y, s * 0.32, s * 0.2, -0.3, 0, Math.PI * 2); c.fill(); c.stroke();
for (let i = 0; i < 3; i++) { c.fillStyle = css(tintas[i + 1]); c.beginPath(); c.arc(b.mE.x - s * 0.15 + i * s * 0.13, b.mE.y - s * 0.03, s * 0.05, 0, Math.PI * 2); c.fill(); }
},
malhar(c, x, y, s, k, cor) {
const f = suave(forca(k));
const b = boneco(c, x, y, s, {
olhoY: -0.4 + (1 - f) * 0.5, pE: [-0.22, 0.08], pD: [0.22, -0.08],
bE: [-1.25 - f * 1.75, -1.9 + f * 1.75], bD: [1.25 + f * 1.75, 1.9 - f * 1.75],
}, cor, k);
// halteres
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
for (const m of [b.mE, b.mD]) {
c.fillRect(m.x - s * 0.25, m.y - s * 0.035, s * 0.5, s * 0.07);
for (const lado of [-1, 1]) { caixa(c, m.x + lado * s * 0.28 - s * 0.07, m.y - s * 0.17, s * 0.14, s * 0.34, s * 0.04); c.fill(); c.stroke(); }
}
if (f > 0.85) { // gotinha de suor
const g = (k * 2) % 1;
c.fillStyle = claro(cor, 1 - g);
c.beginPath(); c.arc(b.cabeca.x + s * 0.72, b.cabeca.y + s * 0.3 + g * s * 0.5, s * 0.07, 0, Math.PI * 2); c.fill();
}
},
dormir(c, x, y, s, k, cor) {
// cama com cabeceira, travesseiro e cobertor que sobe e desce com a respiração
const bx0 = x - s * 1.7, bx1 = x + s * 1.9, topo = y - s * 0.75;
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(bx0 + s * 0.12, y); c.lineTo(bx0 + s * 0.12, topo); c.moveTo(bx1 - s * 0.12, y); c.lineTo(bx1 - s * 0.12, topo); c.stroke();
caixa(c, bx0 - s * 0.08, topo - s * 1.25, s * 0.24, s * 1.3, s * 0.1); c.fill(); c.stroke();
caixa(c, bx0, topo - s * 0.3, bx1 - bx0, s * 0.32, s * 0.12); c.fill(); c.stroke();
c.fillStyle = claro(cor, 0.9);
c.beginPath(); c.ellipse(bx0 + s * 0.85, topo - s * 0.5, s * 0.6, s * 0.24, 0, 0, Math.PI * 2); c.fill(); c.stroke();
// bonequinho deitado: desenhado em pé e girado 90°, pés à direita, cabeça no travesseiro
const e = 0.72, linha = topo - s * 0.3 - s * e * 0.5;
c.save(); c.translate(x + s * 1.5, linha); c.rotate(-Math.PI / 2);
boneco(c, 0, 0, s * e, { fechados: true, bE: [-0.1, 0], bD: [0.1, 0] }, cor, k);
c.restore();
const resp = Math.sin(k * 1.4) * s * 0.04;
c.fillStyle = css(cor.map((v) => v * 0.45)); c.strokeStyle = css(cor);
caixa(c, x - s * 0.3, linha - s * 0.42 - resp, s * 2.15, topo - s * 0.3 - (linha - s * 0.42 - resp) + s * 0.06, s * 0.18); c.fill(); c.stroke();
c.strokeStyle = css(cor, 0.5); c.beginPath(); c.moveTo(x - s * 0.1, linha - s * 0.2 - resp); c.lineTo(x + s * 1.7, linha - s * 0.2 - resp); c.stroke();
zzz(c, x - s * 0.9, linha - s * 0.9, s, k, cor);
},
cafe(c, x, y, s, k, cor) {
const f = (k % 5) / 5, gole = suave(faixaDe(f, 0.5, 0.64)) * (1 - suave(faixaDe(f, 0.84, 0.97)));
const b = boneco(c, x, y, s, Object.assign({}, SENTADO, {
olhoX: 0.2, olhoY: 0.3 - gole * 0.8, fechados: gole > 0.85, cabeca: -0.04,
bE: [0.15, 0.95], bD: [-0.15 + gole * 1.75, -0.95 - gole * 3.05],
}), cor, k);
xicara(c, b.mD.x, b.mD.y, s, cor);
if (gole < 0.3) vapor(c, b.mD.x, b.mD.y - s * 0.3, s, k, cor);
},
cozinhar(c, x, y, s, k, cor) {
const ciclo = (k % 2.6) / 2.6, voo = ciclo < 0.55 ? ciclo / 0.55 : -1, h = voo >= 0 ? 4 * voo * (1 - voo) : 0;
const fx = x + s * 1.0, fw = s * 1.7, fh = s * 1.2;
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
caixa(c, fx, y - fh, fw, fh, s * 0.08); c.fill(); c.stroke();
caixa(c, fx + fw * 0.15, y - fh * 0.72, fw * 0.7, fh * 0.5, s * 0.06); c.stroke();
for (let i = 0; i < 3; i++) chama(c, fx + fw * (0.3 + 0.2 * i), y - fh, s * (0.22 + 0.07 * Math.sin(k * 17 + i * 2)), s);
const b = boneco(c, x - s * 0.45, y, s, {
olhoX: 1, olhoY: 0.3 - h, cabeca: -0.05, bE: [-0.4, 0.3],
bD: [1.2 - (voo >= 0 && voo < 0.15 ? 0.25 : 0), 0.35],
}, cor, k);
const px = b.mD.x + s * 0.62, py = b.mD.y + s * 0.02;
c.strokeStyle = css(cor); c.lineWidth = Math.max(2, s * 0.09);
c.beginPath(); c.moveTo(b.mD.x, b.mD.y); c.lineTo(px - s * 0.42, py); c.stroke();
c.fillStyle = escuro(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); c.ellipse(px, py, s * 0.46, s * 0.13, 0, 0, Math.PI * 2); c.fill(); c.stroke();
c.save(); c.translate(px, py - s * 0.1 - h * s * 1.6); c.rotate(voo >= 0 ? voo * Math.PI * 2 : 0);
c.fillStyle = css(AMARELO_); c.beginPath(); c.ellipse(0, 0, s * 0.32, s * 0.08, 0, 0, Math.PI * 2); c.fill(); c.stroke();
c.restore();
},
regar(c, x, y, s, k, cor) {
const cresce = Math.min(1, k / 18), px = x + s * 2.0;
// vaso
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(px - s * 0.42, y - s * 0.6); c.lineTo(px + s * 0.42, y - s * 0.6); c.lineTo(px + s * 0.3, y); c.lineTo(px - s * 0.3, y); c.closePath(); c.fill(); c.stroke();
// planta crescendo: caule, folhas e, no fim, a flor
const alt = s * (0.3 + 1.25 * cresce), base = y - s * 0.6, balanca = Math.sin(k * 1.5) * s * 0.06;
c.strokeStyle = css(VERDE_); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(px, base); c.quadraticCurveTo(px + balanca, base - alt / 2, px + balanca * 2, base - alt); c.stroke();
c.fillStyle = css(VERDE_);
for (let i = 1; i <= 3; i++) {
if (cresce < i * 0.22) continue;
const yy = base - alt * i * 0.25, lado = i % 2 ? 1 : -1;
c.beginPath(); c.ellipse(px + balanca * i * 0.5 + lado * s * 0.18, yy, s * 0.2, s * 0.08, lado * -0.5, 0, Math.PI * 2); c.fill();
}
if (cresce > 0.75) {
const f = (cresce - 0.75) / 0.25, fx = px + balanca * 2, fy = base - alt;
c.fillStyle = css(ROSA_);
for (let i = 0; i < 5; i++) { const a = i * 1.2566 + k * 0.3; c.beginPath(); c.arc(fx + Math.cos(a) * s * 0.16 * f, fy + Math.sin(a) * s * 0.16 * f, s * 0.11 * f, 0, Math.PI * 2); c.fill(); }
c.fillStyle = css(AMARELO_); c.beginPath(); c.arc(fx, fy, s * 0.09 * f, 0, Math.PI * 2); c.fill();
}
const b = boneco(c, x - s * 0.7, y, s, { olhoX: 1, olhoY: 0.8, cabeca: -0.1, inclina: -0.06, bD: [1.35, 0.35], bE: [-0.3, 0.2] }, cor, k);
// regador inclinado e as gotas caindo na planta
const ang = 0.38, rx = b.mD.x + s * 0.05, ry = b.mD.y;
c.save(); c.translate(rx, ry); c.rotate(ang);
c.fillStyle = claro(cor, 0.95); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
caixa(c, 0, -s * 0.22, s * 0.6, s * 0.42, s * 0.08); c.fill(); c.stroke();
c.beginPath(); c.moveTo(s * 0.55, -s * 0.05); c.lineTo(s * 1.0, -s * 0.32); c.stroke();
c.restore();
const ex = rx + Math.cos(ang) * s * 1.0 + Math.sin(ang) * s * 0.32, ey = ry + Math.sin(ang) * s * 1.0 - Math.cos(ang) * s * 0.32;
c.fillStyle = claro(cor, 1);
for (let i = 0; i < 6; i++) {
const f = (k * 1.6 + i / 6) % 1;
c.beginPath(); c.ellipse(ex + (px - ex) * f * 0.8, ey + (base - alt - ey + s * 0.2) * f * f, s * 0.035, s * 0.07, 0, 0, Math.PI * 2); c.fill();
}
},
violao(c, x, y, s, k, cor) {
const toque = Math.sin(k * 11);
const b = boneco(c, x, y, s, Object.assign({}, SENTADO, {
olhoX: -0.4, olhoY: 0.7, cabeca: 0.08 * Math.sin(k * 1.1),
bE: [-1.1, -0.9], bD: [-0.2 + toque * 0.12, -1.0],
}), cor, k);
// violão atravessado no colo: corpo perto da mão que toca, braço até a outra mão
const gx = b.quadril.x + s * 0.3, gy = b.quadril.y - s * 0.35, nx = b.mE.x, ny = b.mE.y;
const a = Math.atan2(ny - gy, nx - gx);
c.save(); c.translate(gx, gy); c.rotate(a);
const L = Math.hypot(nx - gx, ny - gy);
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
caixa(c, s * 0.2, -s * 0.08, L - s * 0.1, s * 0.16, s * 0.04); c.fill(); c.stroke();
caixa(c, L - s * 0.05, -s * 0.12, s * 0.3, s * 0.24, s * 0.06); c.fill(); c.stroke();
c.fillStyle = claro(cor, 0.95);
c.beginPath(); c.arc(-s * 0.28, 0, s * 0.42, 0, Math.PI * 2); c.fill(); c.stroke();
c.beginPath(); c.arc(s * 0.22, 0, s * 0.32, 0, Math.PI * 2); c.fill(); c.stroke();
c.fillStyle = escuro(cor); c.beginPath(); c.arc(s * 0.05, 0, s * 0.11, 0, Math.PI * 2); c.fill();
c.strokeStyle = css(cor, 0.7); c.lineWidth = Math.max(1, s * 0.015);
for (const d of [-0.04, 0, 0.04]) { c.beginPath(); c.moveTo(-s * 0.5, d * s); c.lineTo(L, d * s); c.stroke(); }
c.restore();
c.fillStyle = css(cor); c.beginPath(); c.arc(b.mD.x, b.mD.y, s * 0.13, 0, Math.PI * 2); c.fill();
notas(c, b.cabeca.x - s * 1.6, b.cabeca.y + s * 0.2, s, k, cor, -1);
},
dancar(c, x, y, s, k, cor) {
const u = (Math.sin(k * 5) + 1) / 2, mexe = Math.sin(k * 5) * 0.18;
// globo espelhado e luzinhas no chão
const gx = x, gy = y - s * 4.5;
c.strokeStyle = css(cor, 0.6); c.lineWidth = Math.max(1, s * 0.03);
c.beginPath(); c.moveTo(gx, gy - s * 0.9); c.lineTo(gx, gy - s * 0.35); c.stroke();
c.fillStyle = claro(cor, 0.9); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.arc(gx, gy, s * 0.35, 0, Math.PI * 2); c.fill(); c.stroke();
c.strokeStyle = css(cor, 0.5);
for (let i = -1; i <= 1; i++) { c.beginPath(); c.ellipse(gx, gy, s * 0.35 * Math.abs(Math.cos(k + i)), s * 0.35, 0, 0, Math.PI * 2); c.stroke(); }
const cores = [ROSA_, AMARELO_, VERDE_, cor];
for (let i = 0; i < 6; i++) {
const lx = x + Math.sin(k * 1.3 + i * 1.7) * s * 2.4, alfa = c.globalAlpha;
c.globalAlpha = alfa * 0.35; c.fillStyle = css(cores[i % 4]);
c.beginPath(); c.ellipse(lx, y, s * 0.35, s * 0.07, 0, 0, Math.PI * 2); c.fill();
c.globalAlpha = alfa;
}
boneco(c, x, y, s, {
dx: mexe, inclina: mexe * 0.5, cabeca: -mexe * 0.6, olhoY: -0.2, olhoX: mexe * 3,
bE: [-0.7 - 2.1 * u, 0.5], bD: [0.7 + 2.1 * (1 - u), -0.5],
pE: [-0.1 - u * 0.25, u * 0.4], pD: [0.1 + (1 - u) * 0.25, -(1 - u) * 0.4],
}, cor, k);
for (let i = 0; i < 4; i++) {
const f = (k * 0.7 + i / 4) % 1, alfa = c.globalAlpha;
c.globalAlpha = alfa * Math.sin(f * Math.PI); c.fillStyle = css(cores[i]);
estrela(c, x + Math.cos(i * 1.9) * s * 1.6, y - s * (2 + i * 0.5) - f * s * 0.4, s * 0.12); c.globalAlpha = alfa;
}
},
corda(c, x, y, s, k, cor) {
const th = k * Math.PI * 2 * 0.85, pulo = Math.pow(Math.max(0, Math.cos(th)), 0.6), sobe = pulo * s * 0.45;
c.save(); c.globalAlpha *= 0.3 * (1 - pulo * 0.5); c.fillStyle = css(cor);
c.beginPath(); c.ellipse(x, y, s * 0.6 * (1 - pulo * 0.3), s * 0.08, 0, 0, Math.PI * 2); c.fill(); c.restore();
const b = boneco(c, x, y - sobe, s, {
olhoY: -0.2, bE: [-0.75 + 0.15 * Math.sin(th), 0.6], bD: [0.75 - 0.15 * Math.sin(th), -0.6],
pE: [-0.06, pulo * 0.3], pD: [0.06, -pulo * 0.3],
}, cor, k);
// a corda passa por baixo dos pés quando ele está no alto do pulo
const my = (b.mE.y + b.mD.y) / 2, embaixo = 2 * (y + s * 0.05) - my, emcima = 2 * (b.cabeca.y - s * 1.0) - my;
const cy = (embaixo + emcima) / 2 + Math.cos(th) * (embaixo - emcima) / 2;
c.strokeStyle = claro(cor, 1); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.moveTo(b.mE.x, b.mE.y); c.quadraticCurveTo(x, cy, b.mD.x, b.mD.y); c.stroke();
},
meditar(c, x, y, s, k, cor) {
const flutua = (Math.sin(k * 1.2) + 1) / 2 * s * 0.35, cy = y - s * 1.7 - flutua;
const alfa = c.globalAlpha;
for (let i = 0; i < 3; i++) { // aura
const f = (k * 0.35 + i / 3) % 1;
c.globalAlpha = alfa * 0.35 * (1 - f); c.strokeStyle = css(cor); c.lineWidth = Math.max(1, s * 0.04);
c.beginPath(); c.ellipse(x, cy, s * (1.2 + f * 1.3), s * (1.4 + f * 1.3), 0, 0, Math.PI * 2); c.stroke();
}
c.globalAlpha = alfa * 0.3; c.fillStyle = css(cor);
c.beginPath(); c.ellipse(x, y, s * 0.8 * (1 - flutua / s), s * 0.09, 0, 0, Math.PI * 2); c.fill();
c.globalAlpha = alfa;
boneco(c, x, y - s * 0.3 - flutua, s, {
sentado: true, fechados: true, pE: [-1.45, 2.75], pD: [1.45, -2.75], bE: [-0.75, 0.25], bD: [0.75, -0.25],
}, cor, k);
},
telescopio(c, x, y, s, k, cor) {
// céu estrelado com uma estrela cadente de vez em quando
const alfa = c.globalAlpha;
for (let i = 0; i < 16; i++) {
c.globalAlpha = alfa * (0.35 + 0.65 * Math.abs(Math.sin(k * (1 + rnd(i, 3)) + i)));
c.fillStyle = i % 5 ? claro(cor, 1) : css(AMARELO_);
estrela(c, x + s * (0.2 + rnd(i, 1) * 3.4), y - s * (3.4 + rnd(i, 2) * 1.5), s * (0.05 + 0.05 * rnd(i, 4)));
}
const f = (k % 6) / 1.1;
if (f < 1) {
const sx = x + s * (3.6 - f * 2.2), sy = y - s * (4.9 - f * 0.8);
const g = c.createLinearGradient(sx, sy, sx + s * 0.9, sy - s * 0.33);
g.addColorStop(0, claro(cor, 1)); g.addColorStop(1, css(cor, 0));
c.globalAlpha = alfa * (1 - f); c.strokeStyle = g; c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.moveTo(sx, sy); c.lineTo(sx + s * 0.9, sy - s * 0.33); c.stroke();
}
c.globalAlpha = alfa;
const b = boneco(c, x - s * 0.7, y, s, { olhoX: 1, olhoY: -1, cabeca: 0.15, bD: [2.1, 0.5], bE: [-0.3, 0.2] }, cor, k);
const ox = b.cabeca.x + s * 0.78, oy = b.cabeca.y + s * 0.3, tx = ox + s * 1.6, ty = oy - s * 0.95;
const mx = (ox + tx) / 2, my = (oy + ty) / 2;
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); c.moveTo(mx, my); c.lineTo(mx - s * 0.5, y); c.moveTo(mx, my); c.lineTo(mx + s * 0.5, y); c.moveTo(mx, my); c.lineTo(mx, y); c.stroke();
c.fillStyle = escuro(cor);
c.beginPath(); capsula(c, ox, oy, tx, ty, s * 0.15); c.fill(); c.stroke();
c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(tx, ty, s * 0.17, 0, Math.PI * 2); c.fill(); c.stroke();
},
pipa(c, x, y, s, k, cor) {
const kx = x + s * (2.3 + 0.45 * Math.sin(k * 0.8)), ky = y - s * (4.2 + 0.35 * Math.cos(k * 1.1)), ang = 0.25 * Math.sin(k * 1.6);
const b = boneco(c, x - s * 0.9, y, s, { olhoX: 1, olhoY: -1, cabeca: 0.12, bD: [2.55, 0.35], bE: [-0.4, 0.3] }, cor, k);
c.strokeStyle = css(cor, 0.8); c.lineWidth = Math.max(1, s * 0.025);
c.beginPath(); c.moveTo(b.mD.x, b.mD.y);
c.quadraticCurveTo((b.mD.x + kx) / 2 + s * 0.4, (b.mD.y + ky) / 2 + s * 0.7, kx, ky + s * 0.45); c.stroke();
// rabiola com lacinhos
c.save(); c.translate(kx, ky); c.rotate(ang);
c.strokeStyle = css(cor, 0.8); c.beginPath(); c.moveTo(0, s * 0.5);
for (let i = 1; i <= 6; i++) c.lineTo(Math.sin(k * 3 + i) * s * 0.18, s * (0.5 + i * 0.22));
c.stroke();
for (let i = 2; i <= 6; i += 2) {
const lx = Math.sin(k * 3 + i) * s * 0.18, ly = s * (0.5 + i * 0.22);
c.fillStyle = css(i % 4 ? ROSA_ : AMARELO_);
c.beginPath(); c.moveTo(lx, ly); c.lineTo(lx - s * 0.1, ly - s * 0.06); c.lineTo(lx - s * 0.1, ly + s * 0.06); c.closePath(); c.fill();
c.beginPath(); c.moveTo(lx, ly); c.lineTo(lx + s * 0.1, ly - s * 0.06); c.lineTo(lx + s * 0.1, ly + s * 0.06); c.closePath(); c.fill();
}
const pts = [[0, -0.5], [0.38, 0], [0, 0.5], [-0.38, 0]];
const cores = [ROSA_, AMARELO_, cor, VERDE_];
for (let i = 0; i < 4; i++) {
const [ax, ay] = pts[i], [bx, by] = pts[(i + 1) % 4];
c.fillStyle = css(cores[i]); c.beginPath(); c.moveTo(0, 0); c.lineTo(ax * s, ay * s); c.lineTo(bx * s, by * s); c.closePath(); c.fill();
}
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.04);
c.beginPath(); pts.forEach(([px, py], i) => (i ? c.lineTo(px * s, py * s) : c.moveTo(px * s, py * s))); c.closePath(); c.stroke();
c.restore();
},
bolhas(c, x, y, s, k, cor) {
const b = boneco(c, x - s * 0.6, y, s, { olhoX: 0.8, olhoY: -0.6, bD: [1.6, -4.0], bE: [-0.3, 0.2] }, cor, k);
const wx = b.mD.x + s * 0.35, wy = b.mD.y - s * 0.25;
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.moveTo(b.mD.x, b.mD.y); c.lineTo(wx - s * 0.1, wy + s * 0.08); c.stroke();
c.beginPath(); c.arc(wx, wy, s * 0.12, 0, Math.PI * 2); c.stroke();
const alfa = c.globalAlpha;
for (let i = 0; i < 9; i++) {
const f = (k * 0.22 + i / 9) % 1, r = s * (0.12 + 0.18 * rnd(i, 4));
const bx = wx + f * s * (1.6 + 1.4 * rnd(i, 5)) + Math.sin(k * 2 + i) * s * 0.15, by = wy - f * s * (1.6 + rnd(i, 6));
c.globalAlpha = alfa * Math.min(1, f / 0.08) * (f > 0.9 ? (1 - f) / 0.1 : 1);
c.fillStyle = css(cor, 0.1); c.strokeStyle = css(cor, 0.9); c.lineWidth = Math.max(1, s * 0.03);
c.beginPath(); c.arc(bx, by, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.strokeStyle = claro(cor, 1); c.beginPath(); c.arc(bx, by, r * 0.65, -2.4, -1.6); c.stroke();
}
c.globalAlpha = alfa;
},
skate(c, x, y, s, k, cor) {
const pos = Math.sin(k * 0.9) * 1.3, vel = Math.cos(k * 0.9), ciclo = k % 4;
const ollie = ciclo < 0.7 ? Math.sin(ciclo / 0.7 * Math.PI) : 0, sobe = ollie * s * 0.6;
const bx = x + pos * s, pe = y - s * 0.3 - sobe;
c.save(); c.globalAlpha *= 0.3; c.fillStyle = css(cor);
c.beginPath(); c.ellipse(bx, y, s * 0.9, s * 0.08, 0, 0, Math.PI * 2); c.fill(); c.restore();
c.strokeStyle = css(cor, 0.4); c.lineWidth = Math.max(1, s * 0.03); // risquinhos de velocidade
for (let i = 0; i < 3; i++) { const lx = bx - Math.sign(vel) * s * (1.1 + i * 0.25); c.beginPath(); c.moveTo(lx, pe - s * (0.6 + i * 0.4)); c.lineTo(lx - Math.sign(vel) * s * 0.5 * Math.abs(vel), pe - s * (0.6 + i * 0.4)); c.stroke(); }
boneco(c, bx, pe, s, {
olhoX: vel * 0.8, inclina: -vel * 0.1, bE: [-1.35, 0.25 + 0.2 * Math.sin(k * 3)], bD: [1.35, -0.25 - 0.2 * Math.sin(k * 3)],
pE: [-0.25, 0.35], pD: [0.25, -0.35],
}, cor, k);
c.save(); c.translate(bx, pe); c.rotate(-ollie * 0.25);
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
caixa(c, -s * 0.85, 0, s * 1.7, s * 0.12, s * 0.06); c.fill(); c.stroke();
c.fillStyle = escuro(cor);
for (const lado of [-1, 1]) { c.beginPath(); c.arc(lado * s * 0.55, s * 0.2, s * 0.1, 0, Math.PI * 2); c.fill(); c.stroke(); }
c.restore();
},
computador(c, x, y, s, k, cor) {
const mx0 = x + s * 0.3, mx1 = x + s * 2.7, mt = y - s * 1.45;
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); c.moveTo(mx0 + s * 0.15, mt); c.lineTo(mx0 + s * 0.15, y); c.moveTo(mx1 - s * 0.15, mt); c.lineTo(mx1 - s * 0.15, y); c.stroke();
caixa(c, mx0, mt, mx1 - mx0, s * 0.12, s * 0.04); c.fill(); c.stroke();
const nx = mx0 + s * 0.6, nw = s * 1.4, nh = s * 0.95, ny = mt - s * 0.08 - nh;
caixa(c, nx - s * 0.12, mt - s * 0.08, nw + s * 0.24, s * 0.08, s * 0.03); c.fill(); c.stroke();
caixa(c, nx, ny, nw, nh, s * 0.06); c.fill(); c.stroke();
c.save(); caixa(c, nx + s * 0.06, ny + s * 0.06, nw - s * 0.12, nh - s * 0.12, s * 0.03); c.clip();
const linhas = Math.floor(k * 2.5), visiveis = 6, ini = Math.max(0, linhas - visiveis);
const tintas = [cor, VERDE_, AMARELO_, ROSA_];
for (let i = ini; i < linhas; i++) {
const yy = ny + s * 0.14 + (i - ini) * s * 0.13, recuo = (rnd(i, 7) < 0.4 ? 1 : 0) * s * 0.15;
c.fillStyle = css(tintas[i % 4], 0.85); c.fillRect(nx + s * 0.12 + recuo, yy, (nw - s * 0.4) * (0.3 + 0.6 * rnd(i, 8)), s * 0.05);
}
if (Math.floor(k * 2) % 2) { c.fillStyle = claro(cor, 1); c.fillRect(nx + s * 0.12, ny + s * 0.14 + Math.min(linhas, visiveis) * s * 0.13, s * 0.04, s * 0.08); }
c.restore();
const tecla = Math.sin(k * 20) * 0.05;
boneco(c, x - s * 0.55, y, s, Object.assign({}, SENTADO, {
olhoX: 1, olhoY: 0.2, cabeca: -0.05, bE: [0.9 + tecla, 0.9], bD: [1.35 - tecla, 0.45],
}), cor, k);
},
pescar(c, x, y, s, k, cor) {
const ciclo = k % 8, fisgou = ciclo > 6.2 && ciclo < 7.6, puxa = fisgou ? suave((ciclo - 6.2) / 0.5) : 0;
// água: ondinhas à direita do píer
c.strokeStyle = css(cor, 0.55); c.lineWidth = Math.max(1, s * 0.035);
for (let fila = 0; fila < 3; fila++) {
c.beginPath();
for (let i = 0; i <= 24; i++) {
const wx = x + s * (0.5 + i * 0.14), wy = y - s * 0.05 + fila * s * 0.16 + Math.sin(k * 2 + i * 0.8 + fila) * s * 0.04;
if (i) c.lineTo(wx, wy); else c.moveTo(wx, wy);
}
c.stroke();
}
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
caixa(c, x - s * 2.0, y - s * 0.22, s * 2.5, s * 0.22, s * 0.05); c.fill(); c.stroke();
const b = boneco(c, x - s * 0.8, y - s * 0.22, s, Object.assign({}, SENTADO, {
olhoX: 1, olhoY: 0.6 - puxa * 1.4, bD: [2.0 - puxa * 0.4, -0.9], bE: [0.5, 1.2],
}), cor, k);
const vx = b.mD.x + s * (1.4 - puxa * 0.3), vy = b.mD.y - s * (1.6 + puxa * 0.3);
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); c.moveTo(b.mE.x, b.mE.y); c.lineTo(b.mD.x, b.mD.y); c.lineTo(vx, vy); c.stroke();
const belisca = ciclo > 5.5 && ciclo < 6.2 ? Math.sin(k * 30) * s * 0.06 : 0;
let lx = x + s * 2.6, ly = y - s * 0.05 + Math.sin(k * 2) * s * 0.04 + belisca;
if (fisgou) { // o peixe pula preso na linha
lx = x + s * (2.6 - puxa * 0.6); ly = y - s * (0.2 + Math.sin(puxa * Math.PI) * 1.3 + puxa * 0.3);
c.save(); c.translate(lx, ly + s * 0.25); c.rotate(Math.sin(k * 14) * 0.4);
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.04);
c.beginPath(); c.ellipse(0, 0, s * 0.12, s * 0.28, 0, 0, Math.PI * 2); c.fill(); c.stroke();
c.beginPath(); c.moveTo(0, s * 0.25); c.lineTo(-s * 0.14, s * 0.45); c.lineTo(s * 0.14, s * 0.45); c.closePath(); c.fill(); c.stroke();
c.fillStyle = escuro(cor); c.beginPath(); c.arc(s * 0.04, -s * 0.12, s * 0.03, 0, Math.PI * 2); c.fill();
c.restore();
}
c.strokeStyle = css(cor, 0.85); c.lineWidth = Math.max(1, s * 0.02);
c.beginPath(); c.moveTo(vx, vy); c.quadraticCurveTo((vx + lx) / 2, Math.max(vy, ly) - s * (fisgou ? 0.1 : 0.4), lx, ly); c.stroke();
if (!fisgou) {
c.fillStyle = css(ROSA_); c.beginPath(); c.arc(lx, ly, s * 0.09, Math.PI, 0); c.fill();
c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(lx, ly, s * 0.09, 0, Math.PI); c.fill();
}
},
};
/** Desenha a cena da atividade (camada atrás dos olhos). o = { t, cor }. */
function desenharPalco(o) {
const v = visibilidade(o.t);
if (!v) {
if (palcoTela && palcoTela.style.display !== 'none') palcoTela.style.display = 'none';
return false;
}
if (!palcoTela) {
palcoTela = document.createElement('canvas'); palcoTela.id = 'palcoAtividade';
palcoTela.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:1';
document.body.append(palcoTela); pctx = palcoTela.getContext('2d');
}
palcoTela.style.display = '';
const d = Math.min(1.5, window.devicePixelRatio || 1), W = Math.round(innerWidth * d), H = Math.round(innerHeight * d);
if (palcoTela.width !== W || palcoTela.height !== H) { palcoTela.width = W; palcoTela.height = H; }
const deitado = W >= H * 1.2;
const s = deitado ? H * 0.145 : Math.min(W * 0.075, H * 0.075);
const x = deitado ? W * 0.7 : W / 2, y = deitado ? H * 0.9 : H * 0.95;
const c = pctx, k = o.t - ativ.t0;
c.clearRect(0, 0, W, H);
c.save();
c.globalAlpha = v;
c.translate((1 - v) * (deitado ? W * 0.06 : 0), (1 - v) * (deitado ? 0 : H * 0.08)); // desliza ao entrar
c.strokeStyle = css(o.cor, 0.35); c.lineWidth = Math.max(1, s * 0.04); // chão
c.beginPath(); c.moveTo(x - s * 2.6, y); c.lineTo(Math.min(W * 0.98, x + s * 3.6), y); c.stroke();
c.shadowColor = css(o.cor, 0.55); c.shadowBlur = s * 0.35;
CENAS[ativ.nome](c, x, y, s, k, o.cor);
c.restore();
return true;
}
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = {
@@ -529,5 +1385,10 @@
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, 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),
olharAtividade, palco: desenharPalco,
atividades: Object.fromEntries(Object.entries(ATIVIDADES).map(([k, v]) => [k, { nome: v.nome, emoji: v.emoji }])) };
})();
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Carinha - Wi-Fi com modo de configuracao (ponto de acesso Carinha-Config)
After=systemd-networkd.service network-pre.target
Wants=systemd-networkd.service
[Service]
ExecStart=/usr/bin/python3 /usr/local/sbin/carinha-wifi
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""Wi-Fi da placa com modo de configuração (estilo "Wi-Fi de hotel").
- Rede salva e encontrada: conecta (cliente) e, se pedido, usa o endereço fixo 192.168.1.35.
- Sem rede salva, ou a rede sumiu por alguns minutos: vira o ponto de acesso "Carinha-Config".
Quem conecta nele cai numa página (abre sozinha no celular) para escolher a rede e a senha.
- No modo configuração, sem ninguém conectado, tenta voltar à rede salva a cada 5 minutos
(faltou luz e a placa ligou antes do roteador: não fica presa aqui).
O chip (unisoc_wifi) não faz cliente e ponto de acesso ao mesmo tempo: o serviço alterna entre os
dois. O cabo de rede fica com um endereço fixo de reserva (192.168.1.34) para nunca perder o acesso.
Só usa a biblioteca padrão; hostapd e dnsmasq rodam como processos filhos.
"""
import hashlib
import html
import json
import os
import re
import signal
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs
IFACE = 'wlan0'
CABO = 'end0'
DIR = Path('/etc/carinha-wifi')
REDE = DIR / 'rede.json' # rede salva (só root lê)
AJUSTES = DIR / 'ajustes.json' # nome/senha do ponto de acesso
RUN = Path('/run/carinha-wifi')
ESTADO = RUN / 'estado.json' # para o painel mostrar
NETWORK = Path('/run/systemd/network/30-carinha-wlan0.network')
NETPLAN_CABO = Path('/etc/netplan/10-dhcp-all-interfaces.yaml')
AP_IP = '192.168.4.1'
IP_FIXO, GATEWAY, DNS = '192.168.1.35', '192.168.1.1', '192.168.1.1'
IP_CABO_RESERVA = '192.168.1.34'
ESPERA_CONECTAR = 60 # s para conectar antes de desistir e virar ponto de acesso
QUEDA_TOLERADA = 180 # s sem a rede (já conectado) até virar ponto de acesso
RETENTAR_A_CADA = 300 # s no modo configuração, sem ninguém conectado, para tentar a rede salva
PADRAO_AJUSTES = dict(ap_nome='Carinha-Config', ap_senha='carinha123')
def log(*a):
print('carinha-wifi:', *a, flush=True)
def sh(*cmd, check=False, timeout=20):
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if check and r.returncode:
raise RuntimeError(f'{cmd[0]} falhou: {r.stderr.strip()[:200]}')
return r
def ler_json(caminho, padrao):
try:
return json.loads(Path(caminho).read_text(encoding='utf-8'))
except (OSError, ValueError):
return padrao
def gravar(caminho, dados, modo=0o600):
caminho = Path(caminho)
caminho.parent.mkdir(parents=True, exist_ok=True)
tmp = caminho.with_suffix('.tmp')
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, modo)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(dados if isinstance(dados, str) else json.dumps(dados, ensure_ascii=False, indent=2))
tmp.replace(caminho)
class Wifi:
def __init__(self):
self.modo = None # 'cliente' | 'ap'
self.desde = time.time()
self.ok_em = 0.0
self.erro = ''
self.redes = [] # última busca: [{ssid, sinal, seguranca}]
self.filhos = {}
self.portal = None
self.lock = threading.RLock()
self.trocar_para = None # pedido vindo da página
self.anterior = False # rede salva antes do pedido (False = nenhum pedido em teste)
RUN.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------ utilidades
def ajustes(self):
return dict(PADRAO_AJUSTES, **ler_json(AJUSTES, {}))
def rede(self):
r = ler_json(REDE, None)
return r if isinstance(r, dict) and r.get('ssid') else None
def conectado(self):
link = sh('iw', 'dev', IFACE, 'link').stdout
return 'Connected to' in link
def gateway_wlan(self):
m = re.search(r'default via (\S+)', sh('ip', '-4', 'route', 'show', 'dev', IFACE).stdout)
return m.group(1) if m else (GATEWAY if (self.rede() or {}).get('ip_fixo', True) else '')
def chega_na_rede(self):
"""Associar não basta: o roteador tem que responder PELO Wi-Fi. Uma rede que não é a da casa
(outro roteador, rede de visitante) associa normalmente e deixaria a placa inacessível."""
gw = self.gateway_wlan()
return bool(gw) and sh('ping', '-c', '1', '-W', '2', '-I', IFACE, gw, timeout=6).returncode == 0
def ip_wlan(self):
m = re.search(r'inet (\d+\.\d+\.\d+\.\d+)', sh('ip', '-4', 'addr', 'show', IFACE).stdout)
return m.group(1) if m else ''
def publicar(self):
r = self.rede()
gravar(ESTADO, dict(modo=self.modo, desde=self.desde, rede=r['ssid'] if r else None, ip=self.ip_wlan(),
erro=self.erro, ap=self.ajustes()['ap_nome']), 0o644)
def buscar_redes(self):
"""Busca redes (só funciona como cliente; no modo ponto de acesso usa a última busca)."""
sh('ip', 'link', 'set', IFACE, 'up')
saida = sh('iw', 'dev', IFACE, 'scan', timeout=30).stdout
if not saida:
saida = sh('iw', 'dev', IFACE, 'scan', 'ap-force', timeout=30).stdout
redes, atual = {}, {}
for linha in saida.splitlines():
linha = linha.strip()
if linha.startswith('BSS '):
atual = {}
elif linha.startswith('signal:'):
atual['sinal'] = float(linha.split()[1])
elif linha.startswith('SSID:'):
ssid = linha[5:].strip()
if ssid and '\\x00' not in ssid:
atual['ssid'] = ssid
if ssid not in redes or redes[ssid]['sinal'] < atual.get('sinal', -100):
redes[ssid] = dict(ssid=ssid, sinal=atual.get('sinal', -100), seguranca=True)
if redes:
self.redes = sorted(redes.values(), key=lambda r: -r['sinal'])[:20]
return self.redes
def parar_filhos(self):
for nome, p in list(self.filhos.items()):
if p.poll() is None:
p.terminate()
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill()
self.filhos.clear()
sh('pkill', '-f', f'wpa_supplicant.*{RUN}')
# ------------------------------------------------------------ cliente
def modo_cliente(self, rede):
log('conectando em', rede['ssid'])
self.parar_portal()
self.parar_filhos()
sh('ip', 'addr', 'flush', 'dev', IFACE)
sh('ip', 'link', 'set', IFACE, 'down')
sh('iw', 'dev', IFACE, 'set', 'type', 'managed')
sh('ip', 'link', 'set', IFACE, 'up')
sh('iw', 'dev', IFACE, 'set', 'power_save', 'off') # economia de energia atrasa e derruba a conexão
ssid_hex = rede['ssid'].encode().hex()
if rede.get('senha'):
psk = hashlib.pbkdf2_hmac('sha1', rede['senha'].encode(), rede['ssid'].encode(), 4096, 32).hex()
seg = f'key_mgmt=WPA-PSK\n psk={psk}'
else:
seg = 'key_mgmt=NONE'
gravar(RUN / 'wpa.conf', f'ctrl_interface={RUN}/wpa\ncountry=BR\nnetwork={{\n ssid={ssid_hex}\n scan_ssid=1\n {seg}\n}}\n')
self.filhos['wpa'] = subprocess.Popen(['wpa_supplicant', '-i', IFACE, '-D', 'nl80211', '-c', str(RUN / 'wpa.conf')],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if rede.get('ip_fixo', True):
# rotas do Wi-Fi com métrica maior: com o cabo ligado, o cabo sempre ganha
conteudo = (f'[Match]\nName={IFACE}\n\n[Network]\nDNS={DNS}\n\n[Address]\nAddress={IP_FIXO}/24\nRouteMetric=600\n'
f'\n[Route]\nGateway={GATEWAY}\nMetric=600\n')
else:
conteudo = f'[Match]\nName={IFACE}\n\n[Network]\nDHCP=ipv4\n\n[DHCPv4]\nRouteMetric=600\n'
gravar(NETWORK, conteudo, 0o644) # o systemd-networkd põe o endereço quando conectar
sh('networkctl', 'reload')
sh('networkctl', 'reconfigure', IFACE)
self.modo, self.desde, self.ok_em = 'cliente', time.time(), 0.0
self.publicar()
def liberar_ip_fixo_do_cabo(self):
"""O cabo usava 192.168.1.35: passa para 192.168.1.34 (reserva) para o Wi-Fi assumir o .35."""
try:
texto = NETPLAN_CABO.read_text(encoding='utf-8')
except OSError:
return
if f'- {IP_FIXO}/24' not in texto:
return
log('cabo passa para', IP_CABO_RESERVA, '(o Wi-Fi fica com', IP_FIXO + ')')
NETPLAN_CABO.with_suffix('.yaml.antes-wifi').write_text(texto, encoding='utf-8')
gravar(NETPLAN_CABO, texto.replace(f'- {IP_FIXO}/24', f'- {IP_CABO_RESERVA}/24'), 0o600)
sh('netplan', 'generate')
sh('networkctl', 'reload')
sh('networkctl', 'reconfigure', CABO)
# ------------------------------------------------------------ ponto de acesso
def modo_ap(self, motivo):
log('modo configuração:', motivo)
self.parar_filhos()
try:
NETWORK.unlink()
except FileNotFoundError:
pass
sh('networkctl', 'reload')
if self.modo != 'ap':
try:
self.buscar_redes() # ainda como cliente: guarda a lista para a página
except Exception as e:
log('busca falhou:', e)
aj = self.ajustes()
sh('ip', 'addr', 'flush', 'dev', IFACE)
sh('ip', 'link', 'set', IFACE, 'down')
sh('iw', 'dev', IFACE, 'set', 'type', '__ap')
sh('ip', 'link', 'set', IFACE, 'up')
sh('ip', 'addr', 'add', f'{AP_IP}/24', 'dev', IFACE)
gravar(RUN / 'hostapd.conf', '\n'.join([
f'interface={IFACE}', 'driver=nl80211', f'ssid={aj["ap_nome"]}', 'country_code=BR', 'hw_mode=g', 'channel=6',
'wmm_enabled=1', 'auth_algs=1', 'wpa=2', 'wpa_key_mgmt=WPA-PSK', 'rsn_pairwise=CCMP',
f'wpa_passphrase={aj["ap_senha"]}', '']))
self.filhos['hostapd'] = subprocess.Popen(['hostapd', str(RUN / 'hostapd.conf')],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.filhos['dnsmasq'] = subprocess.Popen([
'dnsmasq', '--keep-in-foreground', '--conf-file=/dev/null', '--no-resolv', '--no-hosts',
f'--interface={IFACE}', '--bind-interfaces', f'--listen-address={AP_IP}', '--except-interface=lo',
'--dhcp-range=192.168.4.10,192.168.4.60,255.255.255.0,1h', f'--dhcp-option=3,{AP_IP}',
f'--dhcp-option=6,{AP_IP}', f'--dhcp-option=114,http://{AP_IP}/', f'--address=/#/{AP_IP}',
f'--pid-file={RUN}/dnsmasq.pid'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.iniciar_portal()
self.modo, self.desde = 'ap', time.time()
self.publicar()
def alguem_no_ap(self):
return 'Station' in sh('iw', 'dev', IFACE, 'station', 'dump').stdout
# ------------------------------------------------------------ página
def iniciar_portal(self):
if self.portal:
return
wifi = self
class Portal(BaseHTTPRequestHandler):
def responder(self, codigo, corpo='', tipo='text/html; charset=utf-8', extra=None):
dados = corpo.encode('utf-8')
self.send_response(codigo)
for k, v in (extra or {}).items():
self.send_header(k, v)
self.send_header('Content-Type', tipo)
self.send_header('Content-Length', str(len(dados)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(dados)
def do_GET(self):
if self.path.split('?')[0] in ('/', '/index.html'):
return self.responder(200, wifi.pagina())
# Android/iPhone/Windows testam "tem internet?": a resposta errada faz a página abrir sozinha
return self.responder(302, extra={'Location': f'http://{AP_IP}/'})
def do_POST(self):
if self.path != '/conectar':
return self.responder(404)
n = min(int(self.headers.get('Content-Length') or 0), 4096)
campos = {k: v[0] for k, v in parse_qs(self.rfile.read(n).decode('utf-8', 'replace')).items()}
ssid = (campos.get('ssid_outra') or campos.get('ssid') or '').strip()
senha = campos.get('senha', '')
if not ssid or len(ssid) > 32 or (senha and not 8 <= len(senha) <= 63):
wifi.erro = 'Escolha a rede e use uma senha de 8 a 63 caracteres (ou deixe vazio se a rede for aberta).'
return self.responder(200, wifi.pagina())
rede = dict(ssid=ssid, senha=senha, ip_fixo=campos.get('ip_fixo') == '1')
with wifi.lock:
wifi.trocar_para = rede
self.responder(200, wifi.pagina(conectando=ssid))
def log_message(self, *a):
pass
try:
self.portal = ThreadingHTTPServer((AP_IP, 80), Portal)
except OSError as e:
log('página não subiu:', e)
return
threading.Thread(target=self.portal.serve_forever, daemon=True).start()
def parar_portal(self):
if self.portal:
self.portal.shutdown()
self.portal.server_close()
self.portal = None
def pagina(self, conectando=None):
r = self.rede()
opcoes = ''.join(
f'<label class="rede"><input type="radio" name="ssid" value="{html.escape(x["ssid"], quote=True)}"'
f'{" checked" if r and r["ssid"] == x["ssid"] else ""}><span>{html.escape(x["ssid"])}</span>'
f'<i>{"▂▄▆█"[:max(1, min(4, int((x["sinal"] + 95) / 12)))]}</i></label>' for x in self.redes)
aviso = f'<p class="erro">{html.escape(self.erro)}</p>' if self.erro else ''
if conectando:
corpo = (f'<h1>Conectando em “{html.escape(conectando)}”…</h1><p>Se der certo, a rede '
f'<b>{html.escape(self.ajustes()["ap_nome"])}</b> some e o Carinha volta ao normal em '
f'<b>http://{IP_FIXO}:8100</b>.</p><p>Se a senha estiver errada, ela volta em cerca de 1 minuto, '
'com o aviso nesta página.</p>')
else:
corpo = f"""<h1>🤖 Wi-Fi do Carinha</h1>
<p class="fraco">{'Rede salva: <b>' + html.escape(r['ssid']) + '</b> (não encontrada agora)' if r else 'Nenhuma rede salva ainda.'}</p>
{aviso}
<form method="post" action="/conectar">
<div class="redes">{opcoes or '<p class="fraco">Nenhuma rede encontrada. Digite o nome abaixo.</p>'}</div>
<label>Outra rede (nome)<input name="ssid_outra" maxlength="32" autocomplete="off"></label>
<label>Senha<input name="senha" type="password" maxlength="63" autocomplete="off"></label>
<label class="caixa"><input type="checkbox" name="ip_fixo" value="1" checked> Manter o endereço {IP_FIXO} (recomendado em casa)</label>
<button>Conectar</button>
</form>"""
return f"""<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Wi-Fi do Carinha</title><style>
:root{{color-scheme:dark;--fundo:#0d1117;--cartao:#161b22;--borda:#30363d;--texto:#e6edf3;--fraco:#8b949e;--azul:#5cd6ff;--erro:#ff7b72}}
body{{margin:0;padding:24px 16px;background:var(--fundo);color:var(--texto);font:16px system-ui,sans-serif}}
main{{max-width:420px;margin:auto;background:var(--cartao);border:1px solid var(--borda);border-radius:16px;padding:20px}}
h1{{font-size:20px;margin:0 0 8px}} .fraco{{color:var(--fraco);font-size:14px}} .erro{{color:var(--erro)}}
form{{display:grid;gap:14px}} label{{display:grid;gap:6px;font-size:14px;color:var(--fraco)}}
input[type=password],input[name=ssid_outra]{{padding:12px;border-radius:10px;border:1px solid var(--borda);background:var(--fundo);color:var(--texto);font-size:16px}}
.redes{{display:grid;gap:6px}} .rede{{display:flex;align-items:center;gap:10px;padding:12px;border:1px solid var(--borda);border-radius:10px;color:var(--texto)}}
.rede span{{flex:1}} .rede i{{font-style:normal;color:var(--azul)}} .caixa{{display:flex;align-items:center;gap:8px}}
button{{padding:14px;border:0;border-radius:10px;background:var(--azul);color:#00222e;font-weight:700;font-size:16px}}
</style></head><body><main>{corpo}</main></body></html>"""
# ------------------------------------------------------------ laço
def rodar(self):
sh('iw', 'reg', 'set', 'BR')
rede = self.rede()
if rede:
self.modo_cliente(rede)
else:
self.modo_ap('nenhuma rede salva')
while True:
time.sleep(3)
try:
self.passo()
except Exception as e: # nunca derruba o serviço
log('erro:', e)
def passo(self):
with self.lock:
pedido, self.trocar_para = self.trocar_para, None
if pedido: # veio da página: tenta a rede nova
time.sleep(2) # deixa a resposta chegar ao celular
self.anterior = self.rede() # se a nova não funcionar, volta para esta
gravar(REDE, pedido)
self.erro = ''
self.modo_cliente(pedido)
return
agora = time.time()
if self.modo == 'cliente':
if self.conectado() and self.ip_wlan() and self.chega_na_rede():
if not self.ok_em:
log('conectado em', self.rede()['ssid'], 'com', self.ip_wlan())
if self.rede().get('ip_fixo', True):
self.liberar_ip_fixo_do_cabo()
self.erro = ''
self.anterior = False
self.ok_em = agora
self.publicar()
return
limite = QUEDA_TOLERADA if self.ok_em else ESPERA_CONECTAR
if agora - (self.ok_em or self.desde) > limite:
if not self.ok_em:
if self.conectado():
self.erro = (f'Conectei em “{self.rede()["ssid"]}”, mas por ela não chego ao roteador da casa '
f'({self.gateway_wlan() or GATEWAY}). Escolha a rede onde ficam o PC e o celular do Carinha.')
else:
self.erro = (f'Não consegui conectar em “{self.rede()["ssid"]}”. Confira a senha e se a rede está '
'ligada, e tente de novo.')
if self.anterior is not False: # a rede nova falhou: a antiga continua salva
if self.anterior:
gravar(REDE, self.anterior)
else:
REDE.unlink(missing_ok=True)
self.anterior = False
self.modo_ap('a rede não respondeu')
elif self.modo == 'ap':
r = self.rede()
if r and agora - self.desde > RETENTAR_A_CADA and not self.alguem_no_ap():
self.modo_cliente(r) # ninguém configurando: tenta a rede salva de novo
def main():
w = Wifi()
def sair(*_):
w.parar_portal()
w.parar_filhos()
raise SystemExit(0)
signal.signal(signal.SIGTERM, sair)
w.rodar()
if __name__ == '__main__':
main()
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Instala o Wi-Fi com modo de configuração na placa (rodar como root, a partir desta pasta).
# Seguro com o cabo ligado: sem rede salva, o serviço só sobe o ponto de acesso no wlan0 e não mexe no cabo.
set -e
cd "$(dirname "$0")"
apt-get install -y --no-install-recommends hostapd dnsmasq-base
# hostapd e dnsmasq rodam como filhos do serviço, nunca sozinhos
systemctl disable --now hostapd 2>/dev/null || true
systemctl mask hostapd 2>/dev/null || true
install -d -m 700 /etc/carinha-wifi
install -m 755 carinha_wifi.py /usr/local/sbin/carinha-wifi
install -m 644 carinha-wifi.service /etc/systemd/system/carinha-wifi.service
# Cabo e Wi-Fi na mesma rede: cada placa de rede só responde pelo próprio endereço (sem "ARP flux")
# e cabo desconectado não segura rota: o tráfego passa direto para o Wi-Fi
printf 'net.ipv4.conf.all.arp_ignore=1\nnet.ipv4.conf.all.arp_announce=2\nnet.ipv4.conf.all.ignore_routes_with_linkdown=1\nnet.ipv4.conf.default.ignore_routes_with_linkdown=1\n' > /etc/sysctl.d/90-carinha-arp.conf
sysctl -q --system
systemctl daemon-reload
systemctl enable carinha-wifi
systemctl restart carinha-wifi
echo "carinha-wifi instalado"
+18 -1
View File
@@ -1,8 +1,25 @@
import unittest
from cerebro import validar_intencao
from cerebro import POR_EVENTO, Cerebro, validar_intencao
class Tests(unittest.TestCase):
def test_only_allowlisted_expression(self):
self.assertEqual(validar_intencao({'gesto':'feliz'}),'feliz')
for data in [{'gesto':'motor 2500'},{'gesto':'feliz','pulso':2500},[],{'gesto':'dormir'}]:
with self.assertRaises(ValueError):validar_intencao(data)
def test_opcoes_por_evento_sem_repetir(self):
c = Cerebro.__new__(Cerebro) # sem thread nem arquivo: só a escolha de opções
c.history = ['surpreso', 'olhar_esq']
opcoes = c.opcoes('som')
self.assertNotIn('surpreso', opcoes) # os dois últimos ficam de fora
self.assertNotIn('olhar_esq', opcoes)
self.assertNotIn('esconder', opcoes) # barulho não vira "esconder o rosto"
self.assertTrue(set(opcoes) <= set(POR_EVENTO['som']))
for evento, lista in POR_EVENTO.items():
for g in lista:
self.assertEqual(validar_intencao({'gesto': g}), g)
self.assertNotIn('acenar', POR_EVENTO['sozinho']) # sozinho: nada de gesto "para alguém"
if __name__=='__main__':unittest.main()
+152
View File
@@ -0,0 +1,152 @@
"""Estúdio de criação: receitas validadas, prompt com exemplos/base, IA local (servidor falso) e tocar."""
import json
import sys
import tempfile
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import criacao as mod # noqa: E402
from criacao import ATIVIDADES_BASE, EXPRESSOES_BASE, Criacao, montar_prompt, validar_atividade, validar_expressao # noqa: E402
RESPOSTAS = {} # o servidor falso devolve o que o teste colocar aqui
PEDIDOS = []
class IaFalsa(BaseHTTPRequestHandler):
def do_POST(self):
corpo = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
PEDIDOS.append(dict(corpo=corpo, auth=self.headers.get('Authorization')))
tipo = 'expressao' if 'passos' in corpo['response_format']['json_schema']['schema']['properties'] else 'atividade'
texto = '<think>pensando...</think>\n```json\n' + json.dumps(RESPOSTAS[tipo]) + '\n```'
dados = json.dumps(dict(choices=[dict(message=dict(content=texto))])).encode()
self.send_response(200); self.send_header('Content-Type', 'application/json'); self.send_header('Content-Length', str(len(dados)))
self.end_headers(); self.wfile.write(dados)
def do_GET(self):
dados = json.dumps(dict(data=[dict(id='qwen-teste')])).encode()
self.send_response(200); self.send_header('Content-Length', str(len(dados))); self.end_headers(); self.wfile.write(dados)
def log_message(self, *a):
pass
class CriacaoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.c = Criacao(Path(self.tmp.name) / 'c.db', Path(self.tmp.name) / 'c.json')
self.ordens = []
self.c.enfileirar = self.ordens.append
def tearDown(self):
self.tmp.cleanup()
def esperar(self):
for _ in range(100):
if not self.c.ocupado:
return
time.sleep(0.05)
self.fail('criação não terminou')
def test_valida_e_limita_expressao(self):
r = validar_expressao(dict(nome='x' * 80, emoji='<script>', maos='voar', espiral='sim', passos=[
dict(forma='feliz', boca='sorriso', cor='roxo', dx=99, dy=-99, esc=5, ab=-1, duracao=50),
dict(forma='inventada', boca='sorriso', cor='rosa', dx=1, dy=1, esc=1, ab=1, duracao=5)]))
self.assertEqual(len(r['nome']), 30)
self.assertEqual(r['emoji'], '✨')
self.assertEqual((r['maos'], r['espiral']), ('nenhuma', False))
p = r['passos'][0]
self.assertEqual((p['cor'], p['dx'], p['dy'], p['esc'], p['ab']), ('escolhida', 24, -12, 1.3, 0))
self.assertEqual(r['passos'][1]['forma'], 'aberto')
self.assertLessEqual(sum(x['duracao'] for x in r['passos'][:2]), 6.01) # encolheu para 6 s
self.assertEqual(r['passos'][-1]['forma'], 'aberto') # volta ao normal
with self.assertRaises(ValueError):
validar_expressao(dict(passos=[]))
for base in EXPRESSOES_BASE.values(): # os exemplos são válidos
self.assertEqual(len(validar_expressao(base)['passos']), len(base['passos']))
def test_valida_atividade(self):
r = validar_atividade(dict(nome='Malabarismo', emoji='🤹', postura='voando', ritmo=99, braco_esquerdo=dict(de=-500, ate=900),
objetos=[dict(tipo='bola', onde='maos', movimento='pular', quantidade=9), dict(tipo='foguete')] * 3))
self.assertEqual(r['postura'], 'em_pe')
self.assertEqual(r['ritmo'], 3)
self.assertEqual(r['braco_esquerdo'], dict(de=-30, ate=180))
self.assertEqual(len(r['objetos']), 3) # o foguete não existe: sai
self.assertEqual(r['objetos'][0]['quantidade'], 5)
for base in ATIVIDADES_BASE.values():
validar_atividade(base)
def test_prompt_reaproveita_o_que_existe(self):
p = montar_prompt('expressao', 'desconfiado', EXPRESSOES_BASE['timido'])
self.assertIn('Exemplos reais que já existem', p)
self.assertIn('Comemorar', p) # exemplo real
self.assertIn('Parta desta receita', p)
self.assertIn('Tímido', p) # a base escolhida
self.assertIn('"desconfiado"', p)
a = montar_prompt('atividade', 'malabarismo')
self.assertIn('halter', a)
self.assertNotIn('Parta desta receita', a)
def test_ia_local_cria_e_toca(self):
srv = ThreadingHTTPServer(('127.0.0.1', 0), IaFalsa)
threading.Thread(target=srv.serve_forever, daemon=True).start()
try:
self.c.configurar(dict(local=dict(url='http://127.0.0.1:%d' % srv.server_port, modelo='qwen-teste', chave='segredo')))
self.assertTrue(self.c.provedores()['local']['pronto'])
self.assertIn('qwen-teste', self.c.testar('local')['mensagem'])
RESPOSTAS['expressao'] = dict(nome='Desconfiado', emoji='🤨', maos='nenhuma', espiral=False, passos=[
dict(forma='bravo', boca='neutra', cor='escolhida', dx=-12, dy=2, esc=0.95, ab=0.6, duracao=0.4),
dict(forma='bravo', boca='neutra', cor='escolhida', dx=12, dy=2, esc=0.95, ab=0.6, duracao=0.8)])
self.c.criar('expressao', 'desconfiado', 'local', base='timido')
with self.assertRaises(ValueError):
self.c.criar('expressao', 'outro', 'local') # um de cada vez
self.esperar()
self.assertEqual(self.c.erro, '')
self.assertEqual(PEDIDOS[-1]['auth'], 'Bearer segredo')
self.assertIn('Tímido', PEDIDOS[-1]['corpo']['messages'][1]['content']) # a base foi junto
item = self.c.lista()[0]
self.assertEqual((item['tipo'], item['nome'], item['ia']), ('expressao', 'Desconfiado', 'local'))
self.assertTrue(self.ordens[-1].startswith('expressao_criada {')) # tocou na hora
RESPOSTAS['atividade'] = dict(ATIVIDADES_BASE['malhar'], nome='Malabarismo', emoji='🤹')
self.c.criar('atividade', 'malabarismo', 'local', base='criado:%d' % item['id']) # base de outro tipo: ignora
self.esperar()
self.assertTrue(self.ordens[-1].startswith('atividade_criada {'))
self.assertEqual(json.loads(self.ordens[-1][17:])['nome'], 'Malabarismo')
self.c.apagar(item['id'])
self.assertEqual([i['nome'] for i in self.c.lista()], ['Malabarismo'])
finally:
srv.shutdown()
def test_figurinha_vai_para_a_biblioteca_de_reacoes(self):
class TreinoFalso:
lock = threading.RLock()
expressao = None
self.c.treinamento = TreinoFalso()
desenho = dict(nome='Sol', local='olhos', formas=[dict(caminho='M 10 10 L 90 10 L 50 90 Z', cor='#ffcc00')])
mod.os.environ['GEMINI_API_KEY'] = 'teste'
original = mod.gemini.gerar_json
mod.gemini.gerar_json = lambda payload: dict(desenho, emoji='☀️')
try:
self.c.criar('figurinha', 'um sol', 'gemini')
self.esperar()
finally:
mod.gemini.gerar_json = original
del mod.os.environ['GEMINI_API_KEY']
self.assertEqual(self.c.erro, '')
self.assertEqual(self.c.lista()[0]['emoji'], '☀️')
self.assertEqual(self.c.treinamento.expressao['receita']['figurinha']['nome'], 'Sol')
def test_erros_claros(self):
with self.assertRaises(ValueError):
self.c.criar('expressao', 'x', 'local') # sem endereço
with self.assertRaises(ValueError):
self.c.configurar(dict(local=dict(url='ftp://nada')))
with self.assertRaises(ValueError):
self.c.criar('robo', 'x', 'gemini')
if __name__ == '__main__':
unittest.main()
+14
View File
@@ -93,6 +93,20 @@ class HistoricoTest(unittest.TestCase):
self.h.marcar_presenca(3000)
self.assertFalse(self.h.checar_ausencia(9999))
def test_pir_segurando_conta_como_presenca(self):
avisos = []
self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados))
self.h.pir['disponivel'] = True
self.h.nivel_pir(True, agora=1000) # subida: evento + bolinha acesa
self.assertTrue(self.h.pir['ativo'])
self.assertEqual(self.h.status()['eventos'][0]['tipo'], 'pir')
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # pino ainda alto: não dorme
self.h.nivel_pir(False, agora=1000 + 10 * 60) # descida: só apaga a bolinha
self.assertFalse(self.h.pir['ativo'])
self.assertEqual(self.h.status()['total'], 1)
self.assertTrue(self.h.checar_ausencia(1000 + 15 * 60 + 1))
self.assertIn(('pir', True), [(t, d.get('ativo')) for t, d in avisos])
def test_apagar_e_limpar(self):
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
b = self.h.registrar('som', agora=1)
+8 -1
View File
@@ -106,9 +106,16 @@ class ServidorTest(unittest.TestCase):
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo'])
for novo in ('acenar', 'esconder', 'espiar', 'pensar', 'musica'): # gestos com mãos e faixa
for novo in ('acenar', 'esconder', 'espiar', 'pensar', 'musica', # gestos com mãos e faixa
'rir', 'bocejar', 'confuso', 'tonto', 'timido', 'comemorar', 'bravo'):
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': novo})[0], 200)
kiosk.FILA.limpar()
# atividades (momentos dele)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'atividade', 'valor': 'videogame'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'atividade', 'valor': 'parar'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'atividade', 'valor': 'voar'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['atividade videogame', 'atividade parar'])
kiosk.FILA.limpar()
kiosk.FILA.adicionar('feliz'); kiosk.FILA.adicionar('rosto miudo')
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo'])
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
+271
View File
@@ -0,0 +1,271 @@
"""Modo vida (bichinho virtual): necessidades, sono, atos, figurinhas com limite e rotas HTTP."""
import http.client
import importlib.util
import json
import random
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from vida import Vida # noqa: E402
spec = importlib.util.spec_from_file_location('kiosk_vida', ROOT / 'kiosk-server.py')
kiosk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kiosk)
DIA = time.mktime((2026, 10, 6, 15, 0, 0, 0, 0, -1)) # 15h: longe da noite
class TreinoFalso:
def __init__(self):
self.pedidos, self.tocadas = [], []
def gerar_figurinha(self, assunto):
self.pedidos.append(assunto)
def status(self):
return dict(reacoes=[dict(id=7, receita=dict(nome='Dança'), feedback=0, ativa=True)])
def testar(self, ident):
self.tocadas.append(ident)
class VidaTest(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.v.enfileirar = self.ordens.append
self.v.ultimo_tick = DIA
def tearDown(self):
self.tmp.cleanup()
def test_necessidades_caem_e_sono_recupera(self):
self.v.necessidades.update(energia=50, social=50, diversao=50)
self.v.passar_tempo(DIA + 600) # 10 min acordado
self.assertLess(self.v.necessidades['energia'], 50)
self.assertLess(self.v.necessidades['diversao'], 50)
self.v.dormindo_proprio = True
antes = self.v.necessidades['energia']
self.v.passar_tempo(DIA + 1200) # 10 min dormindo
self.assertGreater(self.v.necessidades['energia'], antes)
def test_pausa_longa_nao_zera(self):
self.v.passar_tempo(DIA + 86400) # placa desligada um dia
self.assertGreater(self.v.necessidades['energia'], 60)
def test_cansado_vai_dormir_e_acorda_descansado(self):
self.v.necessidades['energia'] = 10
self.assertEqual(self.v.agir(DIA), 'dormir')
self.assertIn('dormir', self.ordens)
self.assertTrue(self.v.dormindo())
self.assertIsNone(self.v.agir(DIA + 60)) # ainda sem energia: continua dormindo
self.v.necessidades['energia'] = 100
self.assertEqual(self.v.agir(DIA + 120), 'acordar')
def test_madrugada_dorme_mesmo_com_gente_se_mexendo(self):
noite = time.mktime((2026, 10, 6, 23, 30, 0, 0, 0, -1))
self.v.alguem_perto = lambda: True # deitado na cama, o PIR vê
self.assertEqual(self.v.agir(noite), 'dormir')
self.v.necessidades['energia'] = 100
self.assertIsNone(self.v.agir(noite + 60)) # descansado, mas é madrugada: não acorda
self.v.estimulo('carinho', noite + 120) # carinho acorda e segura acordado um tempo
self.assertFalse(self.v.dormindo())
self.assertNotEqual(self.v.agir(noite + 180), 'dormir')
self.assertEqual(self.v.agir(noite + 180 + 11 * 60), 'dormir')
def test_ritmo_vira_sensibilidade(self):
self.v.configurar({'ritmo': 'calmo'})
self.assertIn('ritmo calmo', self.ordens)
self.assertFalse(self.v.pir_acorda(DIA)) # calmo: 3 disparos do PIR em 2 min
self.assertFalse(self.v.pir_acorda(DIA + 30))
self.assertTrue(self.v.pir_acorda(DIA + 60))
self.assertFalse(self.v.pir_acorda(DIA + 400)) # os antigos expiraram
self.v.configurar({'ritmo': 'agitado'})
self.assertTrue(self.v.pir_acorda(DIA + 500)) # agitado: um basta
self.ordens.clear()
self.v.tick(DIA + 505)
self.assertIn('ritmo agitado', self.ordens) # reenviado junto com o humor
self.v.configurar({'ligado': False})
self.assertIn('ritmo normal', self.ordens)
self.assertTrue(self.v.pir_acorda(DIA + 600)) # desligado: comportamento de sempre
def test_atividades_pelo_humor(self):
self.v.celular_online = lambda: True
self.v.necessidades.update(energia=80, alegria=70, social=70, diversao=10) # entediado
self.v.rng.random = lambda: 0.0 # sorteio sempre "sim"
ato = self.v.agir(DIA) # entediado: vai fazer algo
self.assertTrue(ato.startswith('atividade '))
nome, segundos = self.ordens[-1].split()[1:]
self.assertEqual(ato, 'atividade ' + nome)
self.assertTrue(120 <= float(segundos) <= 300) # por alguns minutos
self.assertEqual(self.v.diario[0]['tipo'], 'atividade')
self.v.celular_atividade = lambda: nome # fazendo: diversão sobe
self.v.passar_tempo(DIA + 180)
self.assertGreater(self.v.necessidades['diversao'], 10)
self.v.celular_atividade = lambda: 'videogame' # ocupado: não interrompe
self.assertIsNone(self.v.agir(DIA + 60))
self.v.celular_atividade = lambda: None
noite = time.mktime((2026, 10, 6, 2, 0, 0, 0, 0, -1))
self.v.config['noite'] = False
self.assertFalse(str(self.v.agir(noite)).startswith('atividade')) # de madrugada, nunca
def test_exausto_dorme_e_o_sono_e_respeitado(self):
self.v.necessidades['energia'] = 15
self.assertEqual(self.v.agir(DIA), 'dormir')
self.assertTrue(self.v.sono_profundo)
self.assertIn('sono profundo', self.ordens)
self.v.necessidades['energia'] = 60
self.assertIsNone(self.v.agir(DIA + 60)) # ainda não descansou
self.v.necessidades['energia'] = 96
self.assertEqual(self.v.agir(DIA + 120), 'acordar')
self.assertFalse(self.v.sono_profundo)
self.assertIn('sono leve', self.ordens)
def test_acordado_de_proposito_encerra_o_sono_pesado(self):
self.v.necessidades['energia'] = 15
self.v.agir(DIA)
self.v.celular_dormindo = lambda: False # tocaram na tela
self.v.tick(DIA + 30)
self.assertFalse(self.v.sono_profundo)
self.assertEqual(self.v.diario[0]['texto'], 'Acordou antes de descansar')
def test_cochilo_quando_cansado(self):
self.v.celular_online = lambda: True
self.v.rng.random = lambda: 0.0
self.v.necessidades.update(energia=35, diversao=80)
self.assertEqual(self.v.agir(DIA), 'atividade dormir')
self.v.celular_atividade = lambda: 'dormir' # na caminha: recupera
antes = self.v.necessidades['energia']
self.v.passar_tempo(DIA + 300)
self.assertGreater(self.v.necessidades['energia'], antes)
def test_alegria_caindo_deixa_bravo(self):
self.v.necessidades.update(energia=80, social=80, diversao=80, alegria=100)
self.assertEqual(self.v.zanga(), 0.0)
self.v.necessidades['alegria'] = 42
self.assertEqual(self.v.zanga(), 0.4)
self.v.tick(DIA + 5)
self.assertIn('zanga 0.4', self.ordens)
self.v.necessidades['alegria'] = 20
self.assertEqual(self.v.humor(), 'bravo')
self.assertEqual(self.v.zanga(), 0.9)
self.v.dormindo_proprio = True # dormindo, sem cara feia
self.assertEqual(self.v.zanga(), 0.0)
def test_sozinho_vive_a_vida_dele(self):
self.v.celular_online = lambda: True
self.v.alguem_perto = lambda: False
self.v.visto_em = DIA - 10 * 60 # ninguém há 10 min
self.v.necessidades.update(energia=80, alegria=40, social=10, diversao=80) # carente
self.v.rng.random = lambda: 0.99 # sorteios de atividade: não
for i in range(20):
gesto = self.v.agir(DIA + i * 100)
self.assertNotIn(gesto, ('acenar', 'coracao', 'piscadinha', 'timido', 'esconder', 'espiar'))
self.v.rng.random = lambda: 0.0 # sorteio: sim
self.v.atividade_em = 0
self.assertTrue(str(self.v.agir(DIA + 3000)).startswith('atividade ')) # sem plateia: vai fazer algo
def test_horario_da_madrugada(self):
from controle_pc import madrugada
h = lambda hora: time.mktime((2026, 10, 6, hora, 10, 0, 0, 0, -1))
cfg = dict(noiteSilenciosa=True, noiteInicio=23, noiteFim=7)
self.assertEqual([madrugada(cfg, h(x)) for x in (22, 23, 3, 6, 7, 12)], [False, True, True, True, False, False])
self.assertTrue(madrugada(dict(cfg, noiteInicio=1, noiteFim=5), h(2)))
self.assertFalse(madrugada(dict(cfg, noiteSilenciosa=False), h(3)))
def test_humor_e_gestos_de_carente_com_gente_perto(self):
self.v.necessidades.update(energia=80, social=10)
self.assertEqual(self.v.humor(), 'carente')
self.v.alguem_perto = lambda: True
gesto = self.v.agir(DIA)
self.assertIn(gesto, ('acenar', 'espiar', 'coracao', 'surpreso'))
self.assertEqual(self.v.diario[0]['tipo'], 'acao')
def test_cuidados_sobem_necessidades_e_mandam_gesto(self):
self.v.necessidades['social'] = 20
st = self.v.estimulo('carinho', DIA)
self.assertEqual(st['necessidades']['social'], 35)
self.assertEqual(self.ordens[-1], 'coracao')
self.v.estimulo('ninar', DIA)
self.assertTrue(self.v.dormindo())
with self.assertRaises(ValueError):
self.v.estimulo('voar', DIA)
def test_figurinha_com_limite(self):
treino = TreinoFalso()
self.v.treinamento = treino
self.v.pode_gemini = lambda: True
self.assertEqual(self.v.motivo_sem_emoji(DIA), '')
self.assertTrue(self.v.criar_figurinha(DIA, 'feliz'))
self.assertIn('à tarde', treino.pedidos[0])
self.assertEqual(self.v.motivo_sem_emoji(DIA + 60), 'descansando a criatividade')
self.v.config['max_emojis_dia'] = 1
self.assertEqual(self.v.motivo_sem_emoji(DIA + 4 * 3600), 'limite do dia')
self.v.pode_gemini = lambda: False
self.assertEqual(self.v.motivo_sem_emoji(DIA + 4 * 3600), 'sem chave Gemini')
def test_tick_manda_humor_e_respeita_bloqueio(self):
self.v.proximo_ato = DIA + 999
self.v.tick(DIA + 5)
self.assertTrue(any(o.startswith('humor ') for o in self.ordens))
self.ordens.clear()
self.v.bloqueado = lambda: True
self.v.proximo_ato = DIA
self.assertIsNone(self.v.tick(DIA + 70))
self.assertEqual(self.ordens, [])
def test_desligado_nao_age_e_config_persiste(self):
self.v.configurar({'ligado': False, 'ritmo': 'calmo'})
self.assertIn('humor normal', self.ordens)
self.v.proximo_ato = DIA
self.assertIsNone(self.v.tick(DIA + 10))
outra = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False)
self.assertFalse(outra.config['ligado'])
self.assertEqual(outra.config['ritmo'], 'calmo')
for ruim in ({'ritmo': 'turbo'}, {'max_emojis_dia': 50}, {'ligado': 1}, {'x': True}):
with self.assertRaises(ValueError):
self.v.configurar(ruim)
class VidaHttpTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
self.server.token = 'token-vida'
self.server.vida = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def tearDown(self):
self.server.shutdown(); self.server.server_close(); self.tmp.cleanup()
def pedir(self, metodo, rota, corpo=None, token='token-vida'):
c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5)
h = {'Content-Type': 'application/json'}
if token:
h['Authorization'] = 'Bearer ' + token
c.request(metodo, rota, body=json.dumps(corpo) if corpo is not None else None, headers=h)
r = c.getresponse(); dados = r.read(); c.close()
return r.status, json.loads(dados)
def test_rotas(self):
self.assertEqual(self.pedir('GET', '/vida/status', token=None)[0], 401)
self.assertEqual(self.pedir('POST', '/vida/cuidar', {'acao': 'carinho'}, token=None)[0], 401)
codigo, st = self.pedir('GET', '/vida/status')
self.assertEqual(codigo, 200)
self.assertIn(st['humor'], ('feliz', 'tranquilo', 'carente', 'entediado', 'triste', 'cansado', 'dormindo'))
codigo, st = self.pedir('POST', '/vida/cuidar', {'acao': 'brincar'})
self.assertEqual((codigo, st['diario'][0]['texto']), (200, 'Brincou'))
self.assertEqual(self.pedir('POST', '/vida/cuidar', {'acao': 'voar'})[0], 400)
self.assertFalse(self.pedir('POST', '/vida/config', {'ligado': False})[1]['config']['ligado'])
self.assertIn('vida', self.pedir('GET', '/estado')[1])
if __name__ == '__main__':
unittest.main()
+555
View File
@@ -0,0 +1,555 @@
"""Modo vida: o Carinha como um bichinho virtual (estilo Tamagotchi).
Quatro necessidades (0 a 100, 100 = satisfeito) caem com o tempo e sobem com o que acontece em volta
(presença, conversa, carinho). Delas sai o humor, e do humor saem os atos: gestos, cochilos, chamar
atenção quando tem gente perto, tocar uma reação da biblioteca e, de vez em quando, desenhar uma
figurinha nova com o Gemini. O celular recebe o humor ("humor feliz") e escolhe os gestos soltos dele
a partir disso, então o rosto parece ter vida mesmo entre um ato e outro.
"""
import json
import os
import random
import threading
import time
from pathlib import Path
from comum import gravar_atomico, ler_json
NECESSIDADES = ('energia', 'alegria', 'social', 'diversao')
# Queda por minuto acordado; dormindo, as outras caem bem mais devagar e a energia volta.
QUEDA = dict(energia=0.5, alegria=0.0, social=0.8, diversao=1.0)
RECUPERA_SONO = 2.5 # energia por minuto dormindo (25 -> 100 em ~30 min)
SONO_LENTO = 0.25
RITMOS = {'calmo': (60, 150), 'normal': (25, 75), 'agitado': (12, 35)} # segundos entre atos
# O ritmo também é a sensibilidade: o celular recebe "ritmo x" e ajusta limiares e quantos estímulos
# precisa para reagir/acordar. Aqui, quantos disparos do PIR em 2 min acordam o rosto.
PIR_ACORDAR = {'calmo': 3, 'normal': 1, 'agitado': 1}
PADRAO = dict(ligado=True, criar_emojis=True, max_emojis_dia=3, ritmo='normal', noite=True)
NOITE = (23, 7) # horário padrão da madrugada (o servidor usa o dos ajustes do celular)
PEDIU_ATENCAO = 10 * 60 # de madrugada, só fica acordado se alguém interagiu (carinho, conversa) há pouco
INTERVALO_EMOJI = 90 * 60 # no mínimo 90 min entre duas figurinhas criadas sozinho
# O que cada estímulo faz com as necessidades (e o gesto que sai na hora, se houver).
ESTIMULOS = {
'pir': (dict(social=4, diversao=2), None),
'movimento': (dict(diversao=3, social=1), None),
'som': (dict(diversao=2), None),
'conversa': (dict(social=12, alegria=6, diversao=4), None),
'carinho': (dict(social=15, alegria=10), 'coracao'),
'cutucar': (dict(social=5, alegria=2, diversao=4), None),
'brincar': (dict(diversao=25, alegria=8, energia=-5), 'alegre'),
'alimentar': (dict(energia=30, alegria=4), 'feliz'),
}
CUIDADOS = {'carinho': 'Recebeu carinho', 'cutucar': 'Levou uma cutucada', 'brincar': 'Brincou',
'alimentar': 'Ganhou energia', 'ninar': 'Foi posto para dormir', 'acordar': 'Foi acordado'}
HUMORES = {
'dormindo': ('😴', 'Dormindo'), 'cansado': ('🥱', 'Cansado'), 'carente': ('🥺', 'Carente'),
'entediado': ('😐', 'Entediado'), 'triste': ('😢', 'Triste'), 'bravo': ('😠', 'Mal-humorado'), 'feliz': ('😄', 'Feliz'),
'tranquilo': ('🙂', 'Tranquilo'),
}
# Gestos de cada humor: (gesto, peso, o que vai no diário)
ATOS = {
'feliz': [('alegre', 3, 'Saltitou de alegria'), ('musica', 2, 'Cantarolou'), ('feliz', 3, 'Sorriu à toa'),
('piscadinha', 2, 'Deu uma piscadinha'), ('coracao', 1, 'Ficou apaixonado pelo dia'),
('rir', 2, 'Deu risada sozinho'), ('comemorar', 1, 'Comemorou à toa')],
'tranquilo': [('olhar_esq', 2, 'Olhou em volta'), ('olhar_dir', 2, 'Olhou em volta'), ('piscar2', 2, 'Piscou devagar'),
('feliz', 1, 'Sorriu'), ('pensar', 1, 'Ficou pensativo'), ('timido', 1, 'Ficou tímido')],
'entediado': [('pensar', 3, 'Ficou pensando na vida'), ('olhar_esq', 2, 'Procurou algo para fazer'),
('olhar_dir', 2, 'Procurou algo para fazer'), ('espiar', 2, 'Espiou por trás das mãos'),
('musica', 1, 'Cantou para passar o tempo'), ('bocejar', 2, 'Bocejou de tédio'),
('confuso', 1, 'Ficou confuso com alguma coisa')],
'carente': [('triste', 2, 'Sentiu falta de alguém'), ('olhar_baixo', 2, 'Ficou cabisbaixo'),
('pensar', 1, 'Ficou pensando em alguém')],
'carente_perto': [('acenar', 3, 'Acenou chamando atenção'), ('espiar', 2, 'Espiou quem está aí'),
('coracao', 2, 'Pediu carinho'), ('surpreso', 1, 'Fez cara de "oi!"'), ('timido', 1, 'Ficou tímido'),
('esconder', 1, 'Escondeu o rosto, envergonhado')],
'triste': [('triste', 3, 'Ficou tristinho'), ('olhar_baixo', 2, 'Olhou para baixo'), ('pensar', 1, 'Ficou pensativo')],
'bravo': [('bravo', 3, 'Ficou emburrado'), ('olhar_baixo', 2, 'Bufou de mau humor'), ('confuso', 1, 'Resmungou sozinho')],
'cansado': [('bocejar', 3, 'Bocejou'), ('piscar2', 1, 'Piscou devagar'), ('olhar_baixo', 2, 'Cochilou de olho aberto'),
('tonto', 1, 'Ficou meio tonto de sono')],
}
# Como no The Sims, cada necessidade baixa puxa um comportamento:
# energia < COCHILO: às vezes tira um cochilo (cena da cama); < SONO: dorme de verdade e o sono é
# respeitado (som, movimento e PIR não acordam) até DESCANSADO; alegria caindo: o rosto vai ficando
# bravo (zanga); diversão < TEDIO: vai fazer uma atividade por alguns minutos.
COCHILO, SONO, DESCANSADO, TEDIO = 40, 20, 95, 45
SOZINHO = 3 * 60 # s sem ninguém por perto: ele vive a vida dele (mais atividades, nada de gesto "para alguém")
PARA_ALGUEM = {'acenar', 'coracao', 'piscadinha', 'timido', 'esconder', 'espiar', 'surpreso'}
ATIVAS = ['bola', 'malhar', 'corda', 'skate', 'dancar', 'pipa'] # gastam energia
CALMAS = ['ler', 'cafe', 'meditar', 'telescopio', 'pescar', 'computador', 'regar'] # quase não gastam
CRIATIVAS = ['pintar', 'violao', 'cantar', 'cozinhar', 'bolhas', 'videogame']
NOME_ATIVIDADE = {
'ler': 'Foi ler um livro', 'cantar': 'Foi cantar', 'bola': 'Foi jogar bola', 'videogame': 'Foi jogar videogame',
'pintar': 'Foi pintar um quadro', 'malhar': 'Foi malhar', 'dormir': 'Tirou um cochilo', 'cafe': 'Foi tomar um café',
'cozinhar': 'Foi cozinhar', 'regar': 'Foi regar a plantinha', 'violao': 'Foi tocar violão', 'dancar': 'Foi dançar',
'corda': 'Foi pular corda', 'meditar': 'Foi meditar', 'telescopio': 'Foi olhar as estrelas', 'pipa': 'Foi soltar pipa',
'bolhas': 'Foi fazer bolhas de sabão', 'skate': 'Foi andar de skate', 'computador': 'Foi mexer no computador',
'pescar': 'Foi pescar',
}
# Efeito por minuto enquanto faz a atividade
POR_MINUTO_DIVERSAO, POR_MINUTO_ALEGRIA = 6.0, 1.5
ENERGIA_POR_MINUTO = {**{a: -2.0 for a in ATIVAS}, **{a: -0.2 for a in CALMAS}, 'cafe': 3.0} # café dá pique
# Assuntos das figurinhas que ele inventa sozinho, por humor e por hora do dia.
TEMAS = {
'feliz': ['um sol sorridente', 'um balão colorido', 'um arco-íris', 'um sorvete de casquinha', 'confete de festa'],
'tranquilo': ['uma folha de árvore', 'uma flor', 'um gatinho dormindo', 'uma xícara de chá', 'um vaso com planta'],
'entediado': ['um foguete', 'um planeta com anel', 'uma pipa', 'um dado', 'um controle de videogame', 'um pião'],
'carente': ['um coração com curativo', 'um ursinho de pelúcia', 'uma carta de amor', 'dois corações juntos'],
'triste': ['uma nuvem de chuva', 'um guarda-chuva', 'uma gota d\'água'],
'bravo': ['uma nuvem de tempestade', 'um vulcão', 'um raio', 'uma pimenta'],
'cansado': ['uma lua com touca de dormir', 'uma xícara de café', 'um travesseiro', 'uma bateria fraca'],
}
PERIODOS = [(5, 12, 'de manhã', ['um sol nascendo', 'um pãozinho', 'uma xícara de café']),
(12, 18, 'à tarde', ['um picolé', 'um avião de papel', 'uma bola']),
(18, 24, 'à noite', ['uma lua', 'estrelas', 'uma coruja']),
(0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])]
MAX_DIARIO = 60
def _limitar(v):
return max(0.0, min(100.0, v))
class Vida:
def __init__(self, caminho, iniciar=True, rng=None):
self.caminho = Path(caminho)
self.lock = threading.RLock()
self.rng = rng or random.Random()
self.publicar = lambda tipo, dados: None
self.enfileirar = lambda ordem: None
# Ligações com o resto do servidor (o main preenche; nos testes ficam neutras).
self.treinamento = None
self.celular_dormindo = lambda: None # True/False com o celular online, None sem ele
self.celular_online = lambda: False
self.alguem_perto = lambda: False # PIR/movimento recente
self.celular_atividade = lambda: None # atividade que o celular está fazendo agora
self.sono_profundo = False # dormindo de cansaço: sensores não acordam
self.dormiu_em = 0.0
self.atividade_em = 0.0 # quando começou a última atividade
self.ultima_atividade = None
self.zanga_enviada = None
self.visto_em = time.time() # última vez que alguém esteve por perto
self.bloqueado = lambda: False # E-Stop ou modo presença com ninguém em casa
self.pode_gemini = lambda: bool(os.environ.get('GEMINI_API_KEY'))
self.madrugada = self.noite_padrao # o main troca pelo horário dos ajustes
self.interagiu_em = 0.0
self.pir_eventos = []
agora = time.time()
self.config = dict(PADRAO)
self.necessidades = dict(energia=80.0, alegria=70.0, social=60.0, diversao=60.0)
self.nascimento = agora
self.diario = []
self.emojis = dict(dia='', total=0, ultimo=0.0)
self.dormindo_proprio = False
try:
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo.get('config', {})))
for k in NECESSIDADES:
if isinstance(salvo.get('necessidades', {}).get(k), (int, float)):
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('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):
pass
self.ultimo_tick = agora
self.proximo_ato = agora + 10
self.humor_enviado = (None, 0.0)
self.publicado_em = 0.0
self.salvo_em = agora
if iniciar:
threading.Thread(target=self.loop, daemon=True).start()
# ------------------------------------------------------------------ config
@staticmethod
def validar(dados):
if not isinstance(dados, dict):
raise ValueError('Esperava um objeto')
limpo = {}
for chave, valor in dados.items():
if chave not in PADRAO:
raise ValueError('Ajuste desconhecido: ' + chave)
if chave == 'ritmo':
if valor not in RITMOS:
raise ValueError('Ritmo: calmo, normal ou agitado')
elif chave == 'max_emojis_dia':
if isinstance(valor, bool) or not isinstance(valor, int) or not 0 <= valor <= 10:
raise ValueError('Figurinhas por dia: de 0 a 10')
elif not isinstance(valor, bool):
raise ValueError(chave + ' precisa ser verdadeiro ou falso')
limpo[chave] = valor
return limpo
def configurar(self, dados):
limpo = self.validar(dados)
with self.lock:
antes = self.config['ligado']
self.config.update(limpo)
if 'ligado' in limpo and limpo['ligado'] != antes:
self.anotar('vida', 'Modo autônomo ligado' if limpo['ligado'] else 'Modo autônomo desligado')
if not limpo['ligado']:
self.enfileirar('humor normal') # o celular volta aos gestos soltos de sempre
self.enfileirar('ritmo normal') # e à sensibilidade dos Ajustes
self.enfileirar('zanga 0.0')
if self.sono_profundo:
self.sono_profundo = False
self.enfileirar('sono leve')
self.proximo_ato = time.time() + 5
if self.config['ligado'] and 'ritmo' in limpo:
self.enfileirar('ritmo ' + self.config['ritmo'])
self.humor_enviado = (None, 0.0)
self.salvar()
self.avisar()
return self.status()
def salvar(self):
with self.lock:
dados = dict(config=self.config, necessidades=self.necessidades, nascimento=self.nascimento,
diario=self.diario, emojis=self.emojis)
self.salvo_em = time.time()
try:
gravar_atomico(self.caminho, dados)
except OSError:
pass
# ------------------------------------------------------------------ estado
def dormindo(self):
cel = self.celular_dormindo()
return self.dormindo_proprio if cel is None else bool(cel)
def humor(self):
if self.dormindo():
return 'dormindo'
n = self.necessidades
if n['energia'] < 25:
return 'cansado'
if n['social'] < 25:
return 'carente'
if n['diversao'] < 25:
return 'entediado'
if n['alegria'] < 30:
return 'bravo'
if n['alegria'] >= 65 and n['social'] >= 40 and n['diversao'] >= 40:
return 'feliz'
return 'tranquilo'
def zanga(self):
"""0 a 1: o quanto o rosto está emburrado. Começa abaixo de 60 de alegria e piora devagar."""
if self.dormindo():
return 0.0
return round(max(0.0, min(1.0, (60 - self.necessidades['alegria']) / 45)), 1)
def anotar(self, tipo, texto, agora=None):
with self.lock:
self.diario.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto))
del self.diario[MAX_DIARIO:]
def emojis_hoje(self, agora=None):
dia = time.strftime('%Y-%m-%d', time.localtime(agora or time.time()))
return self.emojis['total'] if self.emojis.get('dia') == dia else 0
def status(self, agora=None):
agora = agora or time.time()
h = self.humor()
with self.lock:
return dict(config=dict(self.config), necessidades={k: round(v) for k, v in self.necessidades.items()},
humor=h, emoji=HUMORES[h][0], nome_humor=HUMORES[h][1], dormindo=self.dormindo(),
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])
def avisar(self):
self.publicado_em = time.time()
self.publicar('vida', self.status())
# ------------------------------------------------------------------ tempo passando
def passar_tempo(self, agora):
minutos = max(0.0, min(10.0, (agora - self.ultimo_tick) / 60)) # pausa longa (placa desligada) não zera tudo
self.ultimo_tick = agora
atividade = self.celular_atividade()
dormindo = self.dormindo() or atividade == 'dormir'
with self.lock:
n = self.necessidades
for k, q in QUEDA.items():
n[k] = _limitar(n[k] - q * minutos * (SONO_LENTO if dormindo else 1))
if dormindo:
n['energia'] = _limitar(n['energia'] + RECUPERA_SONO * minutos)
elif atividade:
n['diversao'] = _limitar(n['diversao'] + (QUEDA['diversao'] + POR_MINUTO_DIVERSAO) * minutos)
n['alegria'] = _limitar(n['alegria'] + POR_MINUTO_ALEGRIA * minutos)
n['energia'] = _limitar(n['energia'] + ENERGIA_POR_MINUTO.get(atividade, -0.5) * minutos)
# alegria acompanha devagar a média de companhia e diversão
alvo = (n['social'] + n['diversao']) / 2 + 10
n['alegria'] = _limitar(n['alegria'] + (alvo - n['alegria']) * min(1.0, 0.05 * minutos))
def estimulo(self, tipo, agora=None):
"""Algo aconteceu em volta (sensor, conversa, toque, botão do painel)."""
if tipo not in ESTIMULOS and tipo not in ('ninar', 'acordar'):
raise ValueError('Cuidado desconhecido')
agora = agora or time.time()
if not self.config['ligado']:
return self.status(agora)
gesto = None
if tipo == 'ninar':
self.dormir(agora, 'Foi posto para dormir')
elif tipo == 'acordar':
self.acordar(agora, 'Foi acordado')
else:
efeito, gesto = ESTIMULOS[tipo]
with self.lock:
for k, v in efeito.items():
self.necessidades[k] = _limitar(self.necessidades[k] + v)
if tipo in CUIDADOS:
self.anotar('cuidado', CUIDADOS[tipo], agora)
if tipo in CUIDADOS or tipo == 'conversa':
self.interagiu_em = agora # alguém quis ele acordado (sensor não conta)
if gesto and not self.bloqueado():
if self.dormindo():
self.acordar(agora, None)
self.enfileirar(gesto)
if tipo in CUIDADOS:
self.avisar()
return self.status(agora)
def dormir(self, agora, motivo, profundo=False):
self.dormindo_proprio = True
self.dormiu_em = agora
self.enfileirar('dormir')
if profundo: # cansado de verdade: sono respeitado até descansar
self.sono_profundo = True
self.enfileirar('sono profundo')
if motivo:
self.anotar('sono', motivo, agora)
def acordar(self, agora, motivo):
self.dormindo_proprio = False
if self.sono_profundo:
self.sono_profundo = False
self.enfileirar('sono leve')
self.enfileirar('acordar')
if motivo:
self.anotar('sono', motivo, agora)
def escolher_atividade(self):
"""Pelo pique: cansado prefere coisas calmas (e às vezes um café); cheio de energia, coisas agitadas."""
e = self.necessidades['energia']
if 35 <= e < 55 and self.rng.random() < 0.35:
return 'cafe'
if e < 50:
lista = CALMAS * 2 + CRIATIVAS
elif e > 70:
lista = ATIVAS * 2 + CRIATIVAS + CALMAS
else:
lista = ATIVAS + CRIATIVAS * 2 + CALMAS
opcoes = [a for a in lista if a != self.ultima_atividade] or lista
return self.rng.choice(opcoes)
def comecar_atividade(self, agora, nome, segundos):
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 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()
self.pir_eventos = [t for t in self.pir_eventos if agora - t < 120] + [agora]
precisa = PIR_ACORDAR[self.config['ritmo']] if self.config['ligado'] else 1
if len(self.pir_eventos) >= precisa:
self.pir_eventos = []
return True
return False
@staticmethod
def noite_padrao(agora):
hora = time.localtime(agora).tm_hour
ini, fim = NOITE
return hora >= ini or hora < fim
# ------------------------------------------------------------------ decidir o que fazer
def escolher(self, opcoes):
total = sum(p for _, p, _ in opcoes)
x = self.rng.uniform(0, total)
for gesto, peso, texto in opcoes:
x -= peso
if x <= 0:
return gesto, texto
return opcoes[-1][0], opcoes[-1][2]
def motivo_sem_emoji(self, agora):
"""'' quando pode criar uma figurinha agora; senão, o porquê (aparece no painel)."""
if not self.config['criar_emojis']:
return 'desligado'
if self.treinamento is None:
return 'biblioteca indisponível'
if not self.pode_gemini():
return 'sem chave Gemini'
if self.emojis_hoje(agora) >= self.config['max_emojis_dia']:
return 'limite do dia'
if agora - float(self.emojis.get('ultimo') or 0) < INTERVALO_EMOJI:
return 'descansando a criatividade'
return ''
def criar_figurinha(self, agora, humor):
h = time.localtime(agora).tm_hour
periodo, extras = next((p, e) for ini, fim, p, e in PERIODOS if ini <= h < fim)
tema = self.rng.choice(TEMAS.get(humor, TEMAS['tranquilo']) + extras)
assunto = (f'Ideia que o próprio Carinha teve sozinho, {periodo}, se sentindo {HUMORES[humor][1].lower()}: '
f'{tema}. Figurinha fofa e simples.')
try:
self.treinamento.gerar_figurinha(assunto)
except ValueError as e:
self.anotar('emoji', 'Quis desenhar ' + tema + ', mas não deu: ' + str(e)[:80], agora)
return False
dia = time.strftime('%Y-%m-%d', time.localtime(agora))
with self.lock:
total = self.emojis['total'] + 1 if self.emojis.get('dia') == dia else 1
self.emojis.update(dia=dia, total=total, ultimo=agora)
self.necessidades['diversao'] = _limitar(self.necessidades['diversao'] + 30)
self.necessidades['alegria'] = _limitar(self.necessidades['alegria'] + 10)
self.anotar('emoji', 'Teve uma ideia e desenhou ' + tema, agora)
return True
def tocar_reacao(self, agora):
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])
try:
self.treinamento.testar(r['id'])
except ValueError:
return False
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()
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')
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')
return 'dormir'
if n['energia'] < SONO:
self.dormir(agora, 'Ficou exausto e foi dormir', profundo=True)
return 'dormir'
humor = self.humor()
if self.celular_atividade():
return None # ocupado com uma atividade: deixa terminar
dia = self.celular_online() and not self.madrugada(agora)
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:
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:
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:
self.visto_em = agora
sozinho = agora - self.visto_em > SOZINHO
if dia and folga and sozinho and self.rng.random() < 0.4:
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:
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):
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)
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.enfileirar(gesto)
if humor == 'entediado':
with self.lock:
n['diversao'] = _limitar(n['diversao'] + 3) # fazer algo distrai um pouquinho
self.anotar('acao', texto, agora)
return gesto
def tick(self, agora=None):
agora = agora or time.time()
self.passar_tempo(agora)
if not self.config['ligado']:
return None
ato = None
humor = self.humor()
# acordaram ele de propósito (toque, painel) antes de descansar: o sono pesado acaba aqui
if self.sono_profundo and not self.dormindo() and agora - self.dormiu_em > 15:
self.sono_profundo = False
self.enfileirar('sono leve')
self.anotar('sono', 'Acordou antes de descansar', agora)
if not self.bloqueado():
# o celular escolhe os gestos soltos pelo humor; reenvia de tempos em tempos (pode ter reiniciado)
zanga = self.zanga()
if humor != self.humor_enviado[0] or agora - self.humor_enviado[1] > 60 or zanga != self.zanga_enviada:
self.enfileirar('humor ' + humor)
self.enfileirar('ritmo ' + self.config['ritmo'])
self.enfileirar('zanga %.1f' % zanga)
if self.sono_profundo:
self.enfileirar('sono profundo')
self.humor_enviado = (humor, agora)
self.zanga_enviada = zanga
if agora >= self.proximo_ato:
ato = self.agir(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():
self.avisar()
if agora - self.salvo_em > 60:
self.salvar()
return ato
def loop(self):
while True:
time.sleep(5)
try:
self.tick()
except Exception as e: # nunca derruba a thread
print('vida: erro no tick:', e, flush=True)
# ------------------------------------------------------------------ HTTP
def rotas(self, h, metodo):
rota = h.path.split('?', 1)[0]
if not rota.startswith('/vida/'):
return False
try:
if metodo == 'GET' and rota == '/vida/status':
resultado = self.status()
elif metodo == 'POST' and rota == '/vida/config':
resultado = self.configurar(ler_json(h, 2048))
elif metodo == 'POST' and rota == '/vida/cuidar':
resultado = self.estimulo(ler_json(h, 2048).get('acao'))
else:
raise ValueError('Rota inválida')
h.enviar(json.dumps(resultado, ensure_ascii=False))
except (ValueError, TypeError) as e:
h.enviar(json.dumps(dict(erro=str(e)), ensure_ascii=False), codigo=400)
return True
+16 -4
View File
@@ -7,7 +7,7 @@
import Paleta from './lib/componentes/Paleta.svelte';
import Selo from './lib/componentes/Selo.svelte';
import Token from './lib/componentes/Token.svelte';
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
import { favoritos, pararTudo, tocarFavorito } from './lib/comandos.svelte';
import { conectar, estado } from './lib/estado.svelte';
import { paleta } from './lib/paleta.svelte';
import { nav, ROTAS, SECUNDARIAS, tituloDa, type Rota } from './lib/rotas.svelte';
@@ -18,6 +18,10 @@
import Reacoes from './telas/Reacoes.svelte';
import Rosto from './telas/Rosto.svelte';
import Eventos from './telas/Eventos.svelte';
import Guia from './telas/Guia.svelte';
import Criar from './telas/Criar.svelte';
import Ajuda from './lib/componentes/Ajuda.svelte';
import { TOPICO_DA_TELA } from './lib/guia';
const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo,
@@ -27,6 +31,8 @@
movimento: Movimento,
ajustes: Ajustes,
rosto: Rosto,
guia: Guia,
criar: Criar,
};
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
@@ -89,7 +95,7 @@
}
if (/^[1-9]$/.test(e.key)) {
const g = favoritos.lista[Number(e.key) - 1];
if (g) gesto(g);
if (g) tocarFavorito(g);
}
}
@@ -141,7 +147,10 @@
</nav>
<header class="topo">
<h1 class="titulo">{tituloDa(nav.atual)}</h1>
<h1 class="titulo">
{tituloDa(nav.atual)}
{#key nav.atual}<Ajuda topico={TOPICO_DA_TELA[nav.atual] ?? 'inicio'} tamanho={17} />{/key}
</h1>
<div class="status linha">
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto}
@@ -172,7 +181,7 @@
<Tela />
</main>
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto'}
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto' && nav.atual !== 'guia' && nav.atual !== 'criar'}
<MiniRosto />
{/if}
</div>
@@ -241,6 +250,9 @@
border-bottom: 1px solid var(--borda);
}
.titulo {
display: flex;
align-items: center;
gap: 2px;
font-size: var(--t-lg);
flex: none;
}
+23 -2
View File
@@ -38,9 +38,11 @@ export interface Campo {
opcoes?: Opcoes;
}
export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] = [
/** `ajuda` = tópico do guia (ⓘ ao lado do título). */
export const GRUPOS: { titulo: string; descricao: string; ajuda: string; campos: Campo[] }[] = [
{
titulo: 'Tela e personalidade',
ajuda: 'tela',
descricao: 'Como o rosto aparece e se comporta sozinho.',
campos: [
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
@@ -55,12 +57,13 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' },
{ chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
{ chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' },
{ chave: 'apagarTela', rotulo: 'Apagar a tela quando dormir', ajuda: 'Tela preta e brilho mínimo; acorda com som, movimento, toque ou PIR', tipo: 'toggle' },
{ chave: 'apagarTela', rotulo: 'Apagar a tela quando dormir', ajuda: 'Tela preta e brilho mínimo; acorda com som repetido, movimento, toque ou PIR', tipo: 'toggle' },
{ chave: 'apagarTelaMin', rotulo: 'Dormindo há quanto tempo para apagar', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' min' },
],
},
{
titulo: 'Sensores e reações',
ajuda: 'sensores',
descricao: 'O que o celular percebe e como reage.',
campos: [
{ chave: 'sensorMic', rotulo: 'Microfone', ajuda: 'Só o nível de som é usado, nunca o áudio', tipo: 'toggle' },
@@ -73,8 +76,25 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
{ chave: 'movolhar', rotulo: 'Quanto os olhos acompanham', tipo: 'slider', min: 0, max: 100, passo: 1, unidade: '%' },
],
},
{
titulo: 'Sono e madrugada',
ajuda: 'sono',
descricao: 'O que acorda o rosto enquanto ele dorme.',
campos: [
{ chave: 'somAcordar', rotulo: 'Sons para acordar', ajuda: 'Quantos barulhos em 1 minuto para ele acordar', tipo: 'slider', min: 1, max: 10, passo: 1 },
{
chave: 'noiteSilenciosa',
rotulo: 'Madrugada silenciosa',
ajuda: 'Movimento, câmera e PIR não acordam; só barulho alto repetido, toque ou conversa',
tipo: 'toggle',
},
{ chave: 'noiteInicio', rotulo: 'Começa às', tipo: 'slider', min: 0, max: 23, passo: 1, unidade: 'h' },
{ chave: 'noiteFim', rotulo: 'Termina às', tipo: 'slider', min: 0, max: 23, passo: 1, unidade: 'h' },
],
},
{
titulo: 'Vídeo e rastreamento',
ajuda: 'camera',
descricao: 'Qualidade da transmissão e rastreamento feito neste PC.',
campos: [
{ chave: 'cameraLado', rotulo: 'Câmera', tipo: 'escolha', opcoes: [['user', 'Frontal'], ['environment', 'Traseira']] },
@@ -98,6 +118,7 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
},
{
titulo: 'Sensibilidade (avançado)',
ajuda: 'sensibilidade',
descricao: 'Limiares dos sustos e intervalos entre reações.',
campos: [
{ chave: 'somlim', rotulo: 'Nível para perceber som', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
+49 -3
View File
@@ -25,21 +25,63 @@ export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
espiar: { nome: 'Espiar', emoji: '🫣' },
pensar: { nome: 'Pensar', emoji: '🤔' },
musica: { nome: 'Dançar', emoji: '🎵' },
rir: { nome: 'Rir', emoji: '😂' },
comemorar: { nome: 'Comemorar', emoji: '🥳' },
timido: { nome: 'Tímido', emoji: '😳' },
confuso: { nome: 'Confuso', emoji: '😕' },
bocejar: { nome: 'Bocejar', emoji: '🥱' },
tonto: { nome: 'Tonto', emoji: '😵‍💫' },
};
/** Atividades (momentos dele): o bonequinho do Carinha faz algo embaixo dos olhos. */
export const ATIVIDADES_INFO: Record<string, { nome: string; emoji: string }> = {
ler: { nome: 'Ler', emoji: '📖' },
cantar: { nome: 'Cantar', emoji: '🎤' },
bola: { nome: 'Jogar bola', emoji: '⚽' },
videogame: { nome: 'Videogame', emoji: '🎮' },
pintar: { nome: 'Pintar', emoji: '🎨' },
malhar: { nome: 'Malhar', emoji: '🏋️' },
dormir: { nome: 'Cochilar', emoji: '🛏️' },
cafe: { nome: 'Café', emoji: '☕' },
cozinhar: { nome: 'Cozinhar', emoji: '🍳' },
regar: { nome: 'Regar planta', emoji: '🪴' },
violao: { nome: 'Violão', emoji: '🎸' },
dancar: { nome: 'Dançar', emoji: '💃' },
corda: { nome: 'Pular corda', emoji: '🪢' },
meditar: { nome: 'Meditar', emoji: '🧘' },
telescopio: { nome: 'Telescópio', emoji: '🔭' },
pipa: { nome: 'Pipa', emoji: '🪁' },
bolhas: { nome: 'Bolhas', emoji: '🫧' },
skate: { nome: 'Skate', emoji: '🛹' },
computador: { nome: 'Computador', emoji: '💻' },
pescar: { nome: 'Pescar', emoji: '🎣' },
};
export const atividade = (nome: string) => tentar(() => postar('/comando', { tipo: 'atividade', valor: nome }));
export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
const CHAVE_FAVORITOS = 'carinha_favoritos';
const CHAVE_MAOS = 'carinha_favoritos_maos'; // marca que os gestos com mãos já entraram
/** Até 12 favoritos; as teclas 1 a 9 valem para os nove primeiros. */
export const MAX_FAVORITOS = 12;
const CHAVE_V3 = 'carinha_favoritos_v3'; // marca que o bravo e os gestos novos já entraram
/** Até 20 favoritos; as teclas 1 a 9 valem para os nove primeiros. */
export const MAX_FAVORITOS = 20;
const GESTOS_MAOS = ['acenar', 'esconder', 'espiar', 'pensar', 'musica'];
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'piscadinha', 'surpreso', 'triste', 'dormir', 'acordar'];
const GESTOS_V3 = ['rir', 'comemorar', 'timido', 'confuso', 'bocejar', 'tonto', 'bravo'];
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'piscadinha', 'surpreso', 'triste',
'dormir', 'acordar', ...GESTOS_V3];
function lerFavoritos(): string[] {
try {
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) {
if (!localStorage.getItem(CHAVE_V3)) {
// Quem já tinha favoritos ganha o bravo de volta e os gestos novos no fim da lista.
const lista = [...v, ...GESTOS_V3.filter((g) => !v.includes(g))].slice(0, MAX_FAVORITOS);
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(lista));
localStorage.setItem(CHAVE_V3, '1');
localStorage.setItem(CHAVE_MAOS, '1');
return lista;
}
if (localStorage.getItem(CHAVE_MAOS)) return v.slice(0, MAX_FAVORITOS);
// Quem já tinha favoritos salvos ganha os gestos com mãos logo depois dos dois primeiros.
const novos = GESTOS_MAOS.filter((g) => !v.includes(g));
@@ -61,6 +103,7 @@ class Favoritos {
try {
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
localStorage.setItem(CHAVE_MAOS, '1');
localStorage.setItem(CHAVE_V3, '1');
} catch {
/* ok */
}
@@ -69,6 +112,9 @@ class Favoritos {
export const favoritos = new Favoritos();
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
/** Favorito pode ser um gesto ou uma criação do estúdio ("criado:12"). */
export const tocarFavorito = (g: string) =>
g.startsWith('criado:') ? tentar(() => postar('/criacao/tocar', { id: Number(g.slice(7)) })) : gesto(g);
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 formato = (nome: string) => tentar(() => postar('/comando', { tipo: 'formato', valor: nome }));
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import { BookOpen, Info } from '@lucide/svelte';
import { TOPICOS } from '../guia';
import Modal from './Modal.svelte';
import TopicoGuia from './TopicoGuia.svelte';
// Botão ⓘ: abre a explicação de um tópico do guia num balão, com atalho para o guia completo.
let { topico, tamanho = 15 }: { topico: string; tamanho?: number } = $props();
const t = $derived(TOPICOS[topico]);
let aberto = $state(false);
</script>
{#if t}
<button
type="button"
class="ajuda"
aria-label={'O que é: ' + t.titulo}
title="O que é isto?"
onclick={(e) => {
e.stopPropagation();
aberto = true;
}}
>
<Info size={tamanho} />
</button>
<Modal bind:aberto titulo={t.emoji + ' ' + t.titulo}>
<TopicoGuia topico={t} />
<a class="btn pequeno guia" href={'#/guia?t=' + topico} onclick={() => (aberto = false)}>
<BookOpen size={14} /> Ver o guia completo
</a>
</Modal>
{/if}
<style>
.ajuda {
display: inline-grid;
place-items: center;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--texto-3);
cursor: pointer;
vertical-align: middle;
text-transform: none;
letter-spacing: 0;
}
.ajuda:hover {
background: var(--superficie-2);
color: var(--destaque);
}
.ajuda:focus-visible {
outline: 2px solid var(--destaque);
outline-offset: 1px;
}
.guia {
justify-self: start;
text-decoration: none;
}
</style>
+5 -2
View File
@@ -1,8 +1,11 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { titulo = '', descricao = '', acoes, children, class: classe = '' }: {
import Ajuda from './Ajuda.svelte';
let { titulo = '', descricao = '', ajuda = '', acoes, children, class: classe = '' }: {
titulo?: string;
descricao?: string;
/** Tópico do guia: mostra um ⓘ ao lado do título. */
ajuda?: string;
acoes?: Snippet;
children: Snippet;
class?: string;
@@ -13,7 +16,7 @@
{#if titulo || acoes}
<header>
<div>
{#if titulo}<h2>{titulo}</h2>{/if}
{#if titulo}<h2>{titulo}{#if ajuda}<Ajuda topico={ajuda} />{/if}</h2>{/if}
{#if descricao}<p class="fraco pequeno-texto">{descricao}</p>{/if}
</div>
{#if acoes}<div class="linha">{@render acoes()}</div>{/if}
+23 -5
View File
@@ -1,17 +1,28 @@
<script lang="ts">
import { Star } from '@lucide/svelte';
import { favoritos, gesto, GESTOS_INFO, gestosDisponiveis } from '../comandos.svelte';
import { favoritos, GESTOS_INFO, gestosDisponiveis, tocarFavorito } from '../comandos.svelte';
import { criacao } from '../criacao.svelte';
// compacto: só os favoritos com atalho 1–9 (dock). Completo: todos, com estrela para fixar.
// compacto: só os favoritos com atalho 1–9 (dock), inclusive criações do estúdio. Completo: todos, com estrela.
let { completo = false }: { completo?: boolean } = $props();
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => g in GESTOS_INFO));
$effect(() => {
if (!completo && !criacao.status && favoritos.lista.some((g) => g.startsWith('criado:'))) criacao.carregar();
});
function infoDe(g: string) {
if (g.startsWith('criado:')) {
const c = criacao.item(Number(g.slice(7)));
return c ? { nome: c.nome, emoji: c.emoji } : null;
}
return GESTOS_INFO[g] ?? null;
}
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => infoDe(g)));
</script>
<div class="gestos" class:completo>
{#each lista as g, i (g)}
{@const info = GESTOS_INFO[g]}
{@const info = infoDe(g)!}
<div class="item">
<button class="gesto" onclick={() => gesto(g)} title={completo || i > 8 ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<button class="gesto" class:criado={g.startsWith('criado:')} onclick={() => tocarFavorito(g)} title={completo || i > 8 ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<span class="emoji" aria-hidden="true">{info.emoji}</span>
<span class="nome">{info.nome}</span>
{#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if}
@@ -71,6 +82,13 @@
color: var(--texto-2);
text-align: center;
}
.gesto.criado::after {
content: '✨';
position: absolute;
left: 6px;
top: 4px;
font-size: 10px;
}
kbd {
position: absolute;
top: 4px;
+9 -2
View File
@@ -1,11 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Ajuda from './Ajuda.svelte';
// Lista agrupada no estilo dos Ajustes do iPhone: título pequeno, cartão arredondado, rodapé de ajuda.
let { titulo = '', rodape = '', children }: { titulo?: string; rodape?: string; children: Snippet } = $props();
// `ajuda` = tópico do guia; aparece um ⓘ ao lado do título.
let { titulo = '', rodape = '', ajuda = '', children }: { titulo?: string; rodape?: string; ajuda?: string; children: Snippet } =
$props();
</script>
<section class="grupo">
{#if titulo}<h2>{titulo}</h2>{/if}
{#if titulo}<h2>{titulo}{#if ajuda}<Ajuda topico={ajuda} />{/if}</h2>{/if}
<div class="itens">{@render children()}</div>
{#if rodape}<p class="rodape">{rodape}</p>{/if}
</section>
@@ -17,6 +20,10 @@
min-width: 0;
}
h2 {
display: flex;
align-items: center;
gap: 2px;
min-height: 26px;
padding: 0 var(--e-4);
font-size: var(--t-xs);
font-weight: 600;
+9 -1
View File
@@ -7,7 +7,8 @@
import { onDestroy } from 'svelte';
import { estado, ouvir } from '../estado.svelte';
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props();
/** espelharSono: dorme/acorda junto com o celular (ele adormece sozinho no silêncio). */
let { titulo = 'Prévia do rosto', espelharSono = true }: { titulo?: string; espelharSono?: boolean } = $props();
let quadro: HTMLIFrameElement;
let pronto = $state(false);
@@ -20,6 +21,13 @@
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
}
// Sono que o celular decidiu sozinho (silêncio, toque, barulho) não passa pelo SSE de ordens:
// vem na telemetria. Sem celular online, a prévia fica como está.
const dormindoNoCelular = $derived(estado.celularOnline ? !!estado.app?.estado?.dormindo : null);
$effect(() => {
if (espelharSono && pronto && dormindoNoCelular !== null) enviar({ tipo: 'sono', dormindo: dormindoNoCelular });
});
let ultimaConversa = estado.conversa?.ultima?.id;
const cancelar = [
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import type { TopicoGuia } from '../guia';
// Conteúdo de um tópico do guia (no balão do ⓘ e na página Guia).
let { topico }: { topico: TopicoGuia } = $props();
</script>
<div class="topico">
<p class="onde">{topico.onde}</p>
<p class="resumo">{topico.resumo}</p>
{#if topico.itens?.length}
<dl>
{#each topico.itens as item (item.nome)}
<div class="item">
<dt>
{#if item.emoji}<span class="emoji" aria-hidden="true">{item.emoji}</span>{/if}
{item.nome}
</dt>
<dd>{item.texto}</dd>
</div>
{/each}
</dl>
{/if}
{#if topico.dica}<p class="dica"><strong>Dica:</strong> {topico.dica}</p>{/if}
</div>
<style>
.topico {
display: grid;
gap: var(--e-3);
}
.onde {
font-size: var(--t-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--texto-3);
}
.resumo {
color: var(--texto);
line-height: 1.5;
max-width: 65ch;
}
dl {
display: grid;
gap: var(--e-3);
margin: 0;
}
.item {
display: grid;
gap: 2px;
}
dt {
display: flex;
align-items: center;
gap: var(--e-2);
font-weight: 600;
}
.emoji {
width: 1.4em;
text-align: center;
}
dd {
margin: 0 0 0 calc(1.4em + var(--e-2));
color: var(--texto-2);
font-size: var(--t-sm);
line-height: 1.45;
max-width: 65ch;
}
.dica {
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
background: var(--destaque-suave);
font-size: var(--t-sm);
}
</style>
+311
View File
@@ -0,0 +1,311 @@
<script lang="ts">
import { Gauge, Moon, Sparkles } from '@lucide/svelte';
import { estado } from '../estado.svelte';
import { configurarVida, cuidar, CUIDADOS, NECESSIDADES } from '../vida.svelte';
import Ajuda from './Ajuda.svelte';
import Grupo from './Grupo.svelte';
import Interruptor from './Interruptor.svelte';
import Linha from './Linha.svelte';
// Cartão do bichinho: humor, necessidades, botões de cuidado e o diário do que ele fez sozinho.
const v = $derived(estado.vida);
let diarioAberto = $state(false);
// O ritmo também é a sensibilidade dos sensores no celular (detalhes no ⓘ).
const RITMOS = [
{ valor: 'calmo', nome: 'Calmo', descricao: 'Reage pouco' },
{ valor: 'normal', nome: 'Normal', descricao: 'Equilibrado' },
{ valor: 'agitado', nome: 'Agitado', descricao: 'Reage a tudo' },
] as const;
const MOTIVOS: Record<string, string> = {
desligado: '',
'sem chave Gemini': 'precisa da chave Gemini (Ajustes)',
'limite do dia': 'limite de hoje atingido',
'descansando a criatividade': 'próxima ideia em breve',
'biblioteca indisponível': 'biblioteca indisponível',
};
function hora(s: number) {
return new Date(s * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function idade(d: number) {
return d === 0 ? 'nasceu hoje' : d === 1 ? '1 dia de vida' : `${d} dias de vida`;
}
const horarioNoite = $derived.by(() => {
const c = estado.appConfig;
const ini = typeof c?.noiteInicio === 'number' ? c.noiteInicio : 23;
const fim = typeof c?.noiteFim === 'number' ? c.noiteFim : 7;
return `Das ${ini}h às ${fim}h. Gente se mexendo não acorda; carinho e conversa sim`;
});
const detalheFigurinhas = $derived(
v ? [`${v.emojis_hoje} de ${v.config.max_emojis_dia} hoje`, MOTIVOS[v.pode_criar] ?? v.pode_criar].filter(Boolean).join(' · ') : '',
);
</script>
<Grupo
titulo="Vida"
ajuda="vida"
rodape={v?.config.ligado
? 'Ele decide sozinho o que fazer pelo humor. Tocar ou deslizar o dedo na tela do celular também conta como carinho.'
: 'Ligado, ele tem necessidades, humor, cochilos e inventa figurinhas, como um bichinho virtual.'}
>
{#if !v}
<p class="vazio">Carregando…</p>
{:else}
<div class="bicho" class:dormindo={v.dormindo}>
<span class="humor" aria-hidden="true">{v.config.ligado ? v.emoji : '🤖'}</span>
<div class="quem">
<strong>{v.config.ligado ? v.nome_humor : 'Modo autônomo desligado'}{#if v.config.ligado}<Ajuda topico="humores" />{/if}</strong>
<span>
{idade(v.idade_dias)}{v.config.ligado && v.sono_profundo ? ' · sono pesado: só acorda descansado' : v.config.ligado && v.diario[0] ? ' · ' + v.diario[0].texto : ''}
</span>
</div>
<Interruptor rotulo="Modo autônomo" checked={v.config.ligado} onchange={(l) => configurarVida({ ligado: l })} />
</div>
{#if v.config.ligado}
<div class="barras">
{#each NECESSIDADES as n (n.chave)}
{@const valor = v.necessidades[n.chave]}
<div class="barra" class:baixa={valor < 25}>
<span class="nome"><span aria-hidden="true">{n.emoji}</span> {n.nome}</span>
<span
class="trilho"
role="meter"
aria-label={n.nome}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={valor}
>
<span class="nivel" style="--v: {valor}%; --c: {n.cor}"></span>
</span>
<span class="num mono">{valor}</span>
</div>
{/each}
</div>
<div class="cuidados">
<span class="cuidar">Cuidar <Ajuda topico="cuidados" /></span>
{#each CUIDADOS as c (c.acao)}
<button class="btn pequeno" onclick={() => cuidar(c.acao)}><span aria-hidden="true">{c.emoji}</span> {c.nome}</button>
{/each}
{#if v.dormindo}
<button class="btn pequeno" onclick={() => cuidar('acordar')}><span aria-hidden="true">☀️</span> Acordar</button>
{:else}
<button class="btn pequeno" onclick={() => cuidar('ninar')}><span aria-hidden="true">🌙</span> Ninar</button>
{/if}
</div>
<Linha icone={Sparkles} cor="#af52de" rotulo="Inventar figurinhas sozinho" detalhe={detalheFigurinhas}>
{#snippet acessorio()}
<Interruptor rotulo="Inventar figurinhas sozinho" checked={v.config.criar_emojis} onchange={(l) => configurarVida({ criar_emojis: l })} />
{/snippet}
</Linha>
<Linha icone={Gauge} cor="#ff9500" rotulo="Ritmo" detalhe={RITMOS.find((r) => r.valor === v.config.ritmo)?.descricao ?? ''}>
{#snippet acessorio()}
<Ajuda topico="ritmo" />
<span class="segmentos" role="radiogroup" aria-label="Ritmo">
{#each RITMOS as r (r.valor)}
<button
role="radio"
aria-checked={v.config.ritmo === r.valor}
class:ativo={v.config.ritmo === r.valor}
onclick={() => configurarVida({ ritmo: r.valor })}>{r.nome}</button
>
{/each}
</span>
{/snippet}
</Linha>
<Linha icone={Moon} cor="#5e5ce6" rotulo="Dormir à noite" detalhe={horarioNoite}>
{#snippet acessorio()}
<Interruptor rotulo="Dormir à noite" checked={v.config.noite} onchange={(l) => configurarVida({ noite: l })} />
{/snippet}
</Linha>
{#if v.diario.length}
<ol class="diario">
{#each v.diario.slice(0, diarioAberto ? 30 : 5) as d (d.horario + d.texto)}
<li class={d.tipo}><time class="mono">{hora(d.horario)}</time> <span>{d.texto}</span></li>
{/each}
</ol>
{#if v.diario.length > 5}
<button class="btn fantasma pequeno mais" onclick={() => (diarioAberto = !diarioAberto)}>
{diarioAberto ? 'Mostrar menos' : 'Ver o diário inteiro'}
</button>
{/if}
{/if}
{/if}
{/if}
</Grupo>
<style>
.vazio {
padding: var(--e-3) var(--e-4);
color: var(--texto-3);
font-size: var(--t-sm);
}
.bicho {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-3) var(--e-4);
}
.humor {
display: grid;
place-items: center;
width: 52px;
height: 52px;
flex: none;
border-radius: 50%;
background: var(--superficie-2);
font-size: 30px;
line-height: 1;
animation: respirar 3.2s ease-in-out infinite;
}
.dormindo .humor {
animation-duration: 6s;
opacity: 0.8;
}
@keyframes respirar {
50% {
transform: scale(1.07);
}
}
.quem {
display: grid;
gap: 2px;
flex: 1;
min-width: 0;
}
.quem strong {
display: flex;
align-items: center;
gap: 2px;
font-size: var(--t-md);
}
.cuidar {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 2px;
margin-bottom: -4px;
font-size: var(--t-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--texto-3);
}
.quem span {
color: var(--texto-3);
font-size: var(--t-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.barras {
display: grid;
gap: var(--e-2);
padding: 0 var(--e-4) var(--e-3);
}
.barra {
display: grid;
grid-template-columns: 7.5em 1fr 2.2em;
align-items: center;
gap: var(--e-3);
font-size: var(--t-sm);
}
.nome {
color: var(--texto-2);
white-space: nowrap;
}
.trilho {
height: 8px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
overflow: hidden;
}
.nivel {
display: block;
width: var(--v);
height: 100%;
border-radius: inherit;
background: var(--c);
transition: width 600ms ease;
}
.num {
text-align: right;
color: var(--texto-3);
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
}
.baixa .num,
.baixa .nome {
color: var(--perigo);
}
.cuidados {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--e-2);
padding: 0 var(--e-4) var(--e-3);
}
.cuidados .btn {
gap: 4px;
padding: 0 var(--e-1);
}
.segmentos {
display: inline-flex;
padding: 2px;
border-radius: var(--raio-sm);
background: var(--superficie-3);
}
.segmentos button {
min-height: 28px;
padding: 0 var(--e-2);
border: 0;
border-radius: calc(var(--raio-sm) - 2px);
background: transparent;
color: var(--texto-2);
font: 500 var(--t-xs) var(--fonte);
cursor: pointer;
}
.segmentos button.ativo {
background: var(--superficie);
color: var(--texto);
box-shadow: var(--sombra);
}
.diario {
display: grid;
gap: 4px;
margin: 0;
padding: var(--e-3) var(--e-4);
border-top: 1px solid var(--borda);
list-style: none;
font-size: var(--t-sm);
}
.diario li {
display: flex;
gap: var(--e-2);
color: var(--texto-2);
}
.diario time {
color: var(--texto-3);
font-size: var(--t-xs);
padding-top: 2px;
}
.diario li.emoji {
color: #af52de;
}
.diario li.cuidado {
color: var(--texto);
}
.mais {
justify-self: start;
margin: 0 var(--e-3) var(--e-2);
}
@media (prefers-reduced-motion: reduce) {
.humor {
animation: none;
}
}
</style>
+100
View File
@@ -0,0 +1,100 @@
/**
* Estúdio de criação (/criacao/*): expressões, atividades e figurinhas feitas por IA.
* O estado chega por SSE ('criacao') e é recarregado ao abrir a tela.
*/
import { pedir, postar } from './api';
import { avisos, tentar } from './avisos.svelte';
import { ouvir } from './estado.svelte';
export type TipoCriacao = 'expressao' | 'atividade' | 'figurinha';
export type IaCriacao = 'gemini' | 'local' | 's10';
export interface ItemCriado {
id: number;
horario: number;
tipo: TipoCriacao;
nome: string;
emoji: string;
receita: any;
ia: IaCriacao;
pedido: string;
}
export interface Provedor {
nome: string;
pronto: boolean;
detalhe: string;
motivo: string;
url?: string;
modelo?: string;
chave?: boolean;
}
export interface StatusCriacao {
ocupado: { tipo: TipoCriacao; ia: IaCriacao; desde: number; pedido: string } | null;
erro: string;
provedores: Record<IaCriacao, Provedor>;
criacoes: ItemCriado[];
bases: Record<'expressao' | 'atividade', { id: string; nome: string; emoji: string }[]>;
}
export const TIPOS: { id: TipoCriacao; nome: string; emoji: string; exemplo: string }[] = [
{ id: 'expressao', nome: 'Expressão', emoji: '😊', exemplo: 'Ex.: desconfiado, olhando de lado com um olho meio fechado' },
{ id: 'atividade', nome: 'Atividade', emoji: '🤹', exemplo: 'Ex.: fazendo malabarismo com 3 bolinhas, concentrado' },
{ id: 'figurinha', nome: 'Figurinha', emoji: '🎨', exemplo: 'Ex.: um sol de óculos escuros sorrindo' },
];
class Criacao {
status = $state<StatusCriacao | null>(null);
async carregar() {
try {
this.status = await pedir<StatusCriacao>('/criacao/status');
} catch (e) {
avisos.erro(e);
}
}
item(id: number) {
return this.status?.criacoes.find((c) => c.id === id) ?? null;
}
async criar(tipo: TipoCriacao, pedido: string, ia: IaCriacao, base: string) {
const r = await tentar(() => postar<StatusCriacao>('/criacao/criar', { tipo, pedido, ia, base: base || null }));
if (r) this.status = r;
return !!r;
}
tocar(id: number) {
return tentar(() => postar('/criacao/tocar', { id }));
}
async apagar(id: number) {
await tentar(() => postar('/criacao/apagar', { id }), 'Criação apagada.');
}
async configurar(local: { url?: string; modelo?: string; chave?: string }) {
const r = await tentar(() => postar<StatusCriacao>('/criacao/config', { local }), 'IA local salva.');
if (r) this.status = r;
}
async testar(ia: IaCriacao) {
const r = await tentar(() => postar<{ mensagem: string }>('/criacao/testar', { ia }));
if (r) avisos.ok(r.mensagem);
}
}
export const criacao = new Criacao();
ouvir('criacao', (d: StatusCriacao) => {
const antes = criacao.status;
criacao.status = d;
// terminou: avisa o resultado
if (antes?.ocupado && !d.ocupado) {
if (d.erro) avisos.erro(new Error(d.erro));
else if (d.criacoes[0]) avisos.ok(`Criado: ${d.criacoes[0].emoji} ${d.criacoes[0].nome}`);
}
});
/** Favoritos podem guardar criações ("criado:12"): tocar no celular. */
export const ehCriado = (g: string) => g.startsWith('criado:');
export const idCriado = (g: string) => Number(g.slice(7));
+20 -6
View File
@@ -4,7 +4,7 @@
*/
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, Vida } from './tipos';
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
@@ -33,9 +33,10 @@ class Estado {
reacao = $state<Reacao | null>(null);
treinamento = $state({ ocupado: false, erro: '' });
api = $state<EstadoServidor['api'] | null>(null);
vida = $state<Vida | null>(null);
feed = $state<ItemFeed[]>([]);
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
pir = $state<{ disponivel: boolean; ultimo: number | null; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
/** Relógio de 1 s para derivar "online" sem esperar evento novo. */
agora = $state(Date.now());
@@ -108,12 +109,20 @@ function aplicarFoto(f: EstadoServidor) {
estado.reacao = f.reacao ?? null;
if (f.treinamento) estado.treinamento = f.treinamento;
if (f.api) estado.api = f.api;
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, presenca: (f.pir as any).presenca };
if (f.vida) estado.vida = f.vida;
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca };
}
const tratadores: Record<string, (d: any) => void> = {
expressao: (d: { ordem: string }) => {
if (d.ordem !== 'calma') noFeed('expressao', 'Expressão: ' + d.ordem);
// 'humor x' é o modo vida avisando o celular; aparece no cartão Vida, não no feed
if (d.ordem === 'calma' || /^(humor|ritmo|zanga|sono) /.test(d.ordem)) return;
const criada = /^(expressao|atividade)_criada (.*)$/.exec(d.ordem);
if (criada) {
let nome = '';
try { nome = JSON.parse(criada[2]).nome; } catch { /* ok */ }
noFeed('expressao', (criada[1] === 'expressao' ? 'Expressão criada: ' : 'Atividade criada: ') + nome);
} else noFeed('expressao', 'Expressão: ' + d.ordem);
},
telemetria: (d: Telemetria) => {
estado.app = d;
@@ -142,7 +151,12 @@ const tratadores: Record<string, (d: any) => void> = {
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
},
treinamento: (d) => (estado.treinamento = d),
pir: (d: { ultimo: number; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, presenca: d.presenca }),
vida: (d: Vida) => {
const novo = d.diario[0];
if (novo && novo.horario !== estado.vida?.diario[0]?.horario && novo.tipo !== 'acao') noFeed('vida', d.emoji + ' ' + novo.texto);
estado.vida = d;
},
pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, presenca: d.presenca }),
historico: (d) => {
const n = d?.novo;
if (n) noFeed('historico', { pir: '🚶 Presença no PIR', movimento: '👀 Movimento na câmera', som: '🔊 Som alto' }[n.tipo as string] ?? 'Detecção');
@@ -169,7 +183,7 @@ function abrirEventos() {
if (estado.conexao !== 'token') estado.conexao = 'caiu';
};
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
'reacao', 'treinamento', 'historico', 'pir']) {
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'criacao']) {
fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados);
+331
View File
@@ -0,0 +1,331 @@
/**
* Guia do painel: o que cada parte faz, em linguagem simples. Usado pelo botão ⓘ ao lado dos
* títulos (abre só o tópico) e pela página Guia (todos os tópicos, como um tutorial).
*/
export interface ItemGuia {
nome: string;
texto: string;
emoji?: string;
}
export interface TopicoGuia {
titulo: string;
emoji: string;
/** Onde fica no painel. */
onde: string;
resumo: string;
itens?: ItemGuia[];
dica?: string;
}
export const TOPICOS: Record<string, TopicoGuia> = {
inicio: {
titulo: 'Primeiros passos',
emoji: '👋',
onde: 'Todo o painel',
resumo:
'O Carinha é um rosto de robô no celular (S10), ligado a esta placa (Orange Pi) que também move o braço. O painel mostra o que ele vê e sente, e deixa você mandar gestos, mudar o rosto e cuidar dele.',
itens: [
{ emoji: '📹', nome: 'Ao vivo', texto: 'Câmera do celular, rosto ao vivo, modo vida, gestos e o que está acontecendo agora.' },
{ emoji: '🔔', nome: 'Eventos', texto: 'Histórico de presença, movimento e som, com foto de cada um.' },
{ emoji: '💬', nome: 'Conversa', texto: 'Converse por texto ou voz; ele responde falando e com um gesto.' },
{ emoji: '😊', nome: 'Reações', texto: 'Biblioteca de animações e figurinhas, e criação de novas com IA.' },
{ emoji: '✋', nome: 'Movimento', texto: 'Controle do braço robótico, calibração e teste dos motores.' },
{ emoji: '⚙️', nome: 'Ajustes', texto: 'Tudo do celular (tela, sensores, sono, vídeo), IA, segurança e sistema.' },
{ emoji: '🛑', nome: 'Parar tudo (E-Stop)', texto: 'O botão vermelho no topo para o braço, os motores e as animações na hora.' },
],
dica: 'O ⓘ ao lado de cada título explica só aquela parte. Ctrl+K abre a busca de comandos.',
},
vida: {
titulo: 'Modo vida (autônomo)',
emoji: '🐣',
onde: 'Ao vivo › Vida',
resumo:
'Com o modo vida ligado, o Carinha funciona como um Sim: tem necessidades que caem com o tempo, e cada necessidade baixa puxa um comportamento. Ele decide sozinho, mas você sempre pode interromper.',
itens: [
{ emoji: '⚡', nome: 'Energia', texto: 'Cai acordado e mais rápido nas atividades agitadas; o café dá um pique. Abaixo de 40%, às vezes tira um cochilo na caminha. Abaixo de 20%, dorme de verdade e o sono é respeitado: som, movimento e PIR não acordam até ele chegar a 95%.' },
{ emoji: '😊', nome: 'Alegria', texto: 'Acompanha a companhia e a diversão. Abaixo de 60%, o rosto vai ficando emburrado aos poucos (testa franzida, boca para baixo e, no fim, olhos bravos). Abaixo de 30%, fica mal-humorado.' },
{ emoji: '💬', nome: 'Companhia', texto: 'Sobe quando alguém aparece (PIR), conversa com ele ou faz carinho. Baixa, ele fica carente.' },
{ emoji: '🎈', nome: 'Diversão', texto: 'Abaixo de 45%, ele vai fazer uma atividade por 2 a 5 minutos (cansado escolhe coisas calmas; cheio de pique, coisas agitadas). Enquanto faz, a diversão sobe.' },
{ emoji: '🛌', nome: 'Sono do celular', texto: 'Com o modo vida ligado, quem decide dormir é a energia; o celular não adormece mais só porque ficou 20 s em silêncio.' },
{ emoji: '🎨', nome: 'Inventar figurinhas sozinho', texto: 'De vez em quando ele tem uma ideia e desenha uma figurinha nova com o Gemini, que vai para a biblioteca de Reações. No máximo 3 por dia, com 90 min entre elas.' },
{ emoji: '🌙', nome: 'Dormir à noite', texto: 'Na madrugada ele vai dormir mesmo com gente se mexendo por perto. Carinho ou conversa seguram ele acordado por 10 minutos.' },
{ emoji: '📖', nome: 'Diário', texto: 'Lista do que ele fez sozinho e dos cuidados que recebeu.' },
],
dica: 'Desligar o modo vida volta o celular aos gestos soltos de sempre e à sensibilidade dos Ajustes.',
},
cuidados: {
titulo: 'Cuidar do Carinha',
emoji: '❤️',
onde: 'Ao vivo › Vida (botões) e a tela do celular',
resumo: 'Os botões do cartão Vida funcionam como os de um Tamagotchi. Cada um mexe nas necessidades e ele reage na hora.',
itens: [
{ emoji: '❤️', nome: 'Carinho', texto: 'Companhia +15 e alegria +10. Ele faz coração.' },
{ emoji: '🎈', nome: 'Brincar', texto: 'Diversão +25 e alegria +8, gasta um pouco de energia. Ele fica alegre.' },
{ emoji: '⚡', nome: 'Energia', texto: 'Energia +30, como se fosse comida. Ele sorri.' },
{ emoji: '🌙', nome: 'Ninar / ☀️ Acordar', texto: 'Põe para dormir ou acorda na hora.' },
{ emoji: '👆', nome: 'Na tela do celular', texto: 'Deslizar o dedo é carinho; tocar é uma cutucada (um pouco de companhia e diversão). Tocar com ele dormindo acorda.' },
],
},
humores: {
titulo: 'Humores',
emoji: '🙂',
onde: 'Ao vivo › Vida (o emoji grande)',
resumo: 'O humor sai das necessidades e escolhe os gestos, as expressões da boca e das sobrancelhas e o quanto ele se mexe.',
itens: [
{ emoji: '😄', nome: 'Feliz', texto: 'Tudo em dia. Saltita, cantarola, dá sorrisinhos e piscadinhas, mexe mais.' },
{ emoji: '🙂', nome: 'Tranquilo', texto: 'Normal. Olha em volta, pisca devagar, meio sorriso.' },
{ emoji: '😐', nome: 'Entediado', texto: 'Diversão baixa. Fica pensativo, boca de lado, procura o que fazer e às vezes toca uma reação da biblioteca.' },
{ emoji: '🥺', nome: 'Carente', texto: 'Companhia baixa. Com alguém perto, acena e pede carinho; sozinho, fica cabisbaixo.' },
{ emoji: '😠', nome: 'Mal-humorado', texto: 'Alegria abaixo de 30%. Testa franzida, olhos bravos, resmunga. Carinho, conversa e brincadeira resolvem.' },
{ emoji: '🥱', nome: 'Cansado', texto: 'Energia baixa. Boceja, suspira e logo vai dormir.' },
{ emoji: '😴', nome: 'Dormindo', texto: 'Olhos fechados, respira devagar e recupera energia.' },
],
},
ritmo: {
titulo: 'Ritmo e sensibilidade',
emoji: '🎚️',
onde: 'Ao vivo › Vida › Ritmo',
resumo: 'O ritmo diz o quanto ele age sozinho e o quanto os sensores precisam ser estimulados para ele reagir ou acordar.',
itens: [
{ emoji: '🐢', nome: 'Calmo', texto: 'Age a cada 1 a 2,5 min. Limiares 50% mais altos; reage só no 3º barulho alto ou movimento forte seguido; acorda com o dobro de sons, 4 movimentos ou 3 disparos do PIR em 2 min.' },
{ emoji: '🙂', nome: 'Normal', texto: 'Age a cada 25 s a 1 min. Reage a barulho alto e movimento forte; acorda com os sons configurados ou 2 movimentos.' },
{ emoji: '⚡', nome: 'Agitado', texto: 'Age a cada 12 a 35 s. Limiares 40% mais baixos; reage a qualquer som ou movimento com reações variadas e acorda no primeiro estímulo.' },
],
dica: 'A madrugada silenciosa vale por cima de qualquer ritmo.',
},
sono: {
titulo: 'Sono e madrugada',
emoji: '🌙',
onde: 'Ajustes › Sono e madrugada',
resumo: 'Decide o que acorda o rosto enquanto ele dorme.',
itens: [
{ emoji: '🔊', nome: 'Sons para acordar', texto: 'Quantos barulhos em 1 minuto ele precisa ouvir para acordar. Um barulho comprido conta como um.' },
{ emoji: '🌌', nome: 'Madrugada silenciosa', texto: 'No horário escolhido, movimento, câmera e PIR não acordam. Só barulho alto repetido, toque na tela ou conversa.' },
{ emoji: '🕚', nome: 'Começa às / Termina às', texto: 'Horário da madrugada (padrão 23h às 7h). Pode atravessar a meia-noite.' },
{ emoji: '⬛', nome: 'Apagar a tela quando dormir', texto: 'Fica em Tela e personalidade: depois de um tempo dormindo, a tela fica preta e o brilho vai ao mínimo para poupar bateria.' },
],
},
celular: {
titulo: 'Celular',
emoji: '📱',
onde: 'Ao vivo › Celular',
resumo: 'Estado do S10: bateria, Wi-Fi, IP, versão do app e se ele está acordado. Tudo vem do próprio aplicativo.',
itens: [
{ emoji: '🔋', nome: 'Bateria', texto: 'Nível e se está carregando. O painel avisa quando está baixa.' },
{ emoji: '🔄', nome: 'Recarregar tela do celular', texto: 'Recarrega o rosto no celular para pegar uma versão nova sem mexer no aparelho.' },
{ emoji: '🕒', nome: 'Último sinal', texto: 'Se o celular sumir (bateria acabou, Wi-Fi caiu), aparece há quanto tempo ele não responde.' },
],
},
camera: {
titulo: 'Câmera e rastreamento',
emoji: '📹',
onde: 'Ao vivo › Câmera e Ajustes › Vídeo e rastreamento',
resumo: 'O vídeo vai direto do celular para este navegador (WebRTC). Nada é gravado; só a foto de cada evento fica no histórico.',
itens: [
{ emoji: '🎥', nome: 'Transmitir câmera do celular', texto: 'Liga o vídeo ao vivo. Toque no quadrinho do canto para trocar câmera e rosto de lugar.' },
{ emoji: '🙂', nome: 'Rastrear rosto / mãos', texto: 'Este computador acha rostos e mãos no vídeo.' },
{ emoji: '👀', nome: 'Olhos seguem o alvo', texto: 'Os olhos do Carinha acompanham o rosto ou a mão encontrada.' },
{ emoji: '✏️', nome: 'Desenhar rastreamento', texto: 'Mostra no vídeo as marcações do que foi encontrado.' },
{ emoji: '🟢', nome: 'Bolinha PIR na imagem', texto: 'Cinza com o sensor parado; verde pulsando enquanto o sensor de presença está vendo alguém.' },
],
},
gestos: {
titulo: 'Gestos',
emoji: '😉',
onde: 'Ao vivo › Gestos e Reações › Gestos do rosto',
resumo: 'Animações prontas do rosto: feliz, surpreso, bravo, acenar, esconder o rosto com as mãos, dançar e outras.',
itens: [
{ emoji: '⭐', nome: 'Favoritos', texto: 'Até 20 gestos fixados com a estrela. Aparecem no Ao vivo.' },
{ emoji: '😂', nome: 'Rir', texto: 'Olhos fechadinhos e boca aberta, pulando de rir.' },
{ emoji: '🥳', nome: 'Comemorar', texto: 'Pula trocando de cor.' },
{ emoji: '😳', nome: 'Tímido', texto: 'Olha para baixo e para o lado, cor-de-rosa.' },
{ emoji: '😕', nome: 'Confuso', texto: 'Um olho meio fechado e a cabeça indo de um lado para o outro.' },
{ emoji: '🥱', nome: 'Bocejar', texto: 'Olhos pesados, a mão na frente da boca e Zzz subindo. Aparece sozinho quando ele está cansado ou entediado.' },
{ emoji: '😵‍💫', nome: 'Tonto', texto: 'Espirais girando no lugar dos olhos e boca de "ó".' },
{ emoji: '⌨️', nome: 'Teclas 1 a 9', texto: 'Disparam os nove primeiros favoritos.' },
{ emoji: '🔍', nome: 'Ctrl+K', texto: 'Busca qualquer gesto, rosto, cor ou tela pelo nome.' },
],
},
aparencia: {
titulo: 'Aparência do rosto',
emoji: '🎨',
onde: 'Ao vivo › Aparência › Rosto, formato e cor',
resumo: 'Escolha o estilo do rosto, o formato dos olhos e a cor. Muda na hora no celular.',
itens: [
{ emoji: '👁️', nome: 'Carinha Viva', texto: 'Pálpebras, brilho e micro-movimentos.' },
{ emoji: '🟦', nome: 'Bloco', texto: 'Olhos quadrados, estilo robô de mesa.' },
{ emoji: '🔵', nome: 'Matriz', texto: 'Painel de LEDs; boca e sobrancelhas também em pontos.' },
{ emoji: '🔘', nome: 'Lente', texto: 'Íris e pupila que dilata.' },
{ emoji: '🙂', nome: 'Clássico, Miúdo e Eilik', texto: 'Os desenhos originais.' },
{ emoji: '⬭', nome: 'Formato', texto: 'Padrão, redondo, pílula, quadrado ou largo (nos rostos novos).' },
{ emoji: '🤨', nome: 'Microexpressões', texto: 'Acordado e sem gesto, a boca e as sobrancelhas mexem sozinhas de vez em quando (meio sorriso, sobrancelha arqueada, suspiro) conforme o humor.' },
],
dica: 'Boca, sobrancelhas e íris ligam e desligam em Ajustes › Tela e personalidade.',
},
atividades: {
titulo: 'Atividades (momentos dele)',
emoji: '🎮',
onde: 'Ao vivo › Gestos › Atividades',
resumo:
'São 20 atividades. A tela se divide: o rosto vai para a esquerda e à direita aparece um bonequinho dele, na cor dos olhos, fazendo a atividade. Os olhos grandes acompanham (seguem a bola, leem as linhas, olham a TV, a pipa, o peixe).',
itens: [
{ emoji: '⚽', nome: 'Agitadas (gastam energia)', texto: 'Jogar bola, malhar, pular corda, skate, dançar (com globo espelhado) e soltar pipa.' },
{ emoji: '📖', nome: 'Calmas', texto: 'Ler, tomar café (dá energia), meditar flutuando, olhar estrelas no telescópio, pescar, computador e regar a plantinha (que cresce e dá flor).' },
{ emoji: '🎨', nome: 'Criativas', texto: 'Pintar, tocar violão, cantar, cozinhar (vira a panqueca), bolhas de sabão e videogame.' },
{ emoji: '🛏️', nome: 'Cochilar', texto: 'Deita na caminha com cobertor e Zzz; recupera energia como dormir.' },
{ emoji: '🐣', nome: 'No modo vida', texto: 'Com a diversão baixa, ele escolhe uma pelo pique e fica nela de 2 a 5 minutos; cansado, cochila. Nunca de madrugada.' },
{ emoji: '👆', nome: 'Parar', texto: 'Botão Parar no painel ou um toque na tela do celular. Dormir também encerra a atividade.' },
],
},
criar: {
titulo: 'Criar com IA',
emoji: '🪄',
onde: 'Ao vivo › Gestos › Criar com IA (ou Reações › Criar com IA)',
resumo:
'Escreva um pedido e uma IA cria uma expressão, uma atividade ou uma figurinha nova. Ela não escreve código: monta a criação só com peças que o Carinha já sabe animar, com exemplos reais do que já existe. Tudo é conferido no servidor antes de chegar ao celular.',
itens: [
{ emoji: '😊', nome: 'Expressão', texto: 'Sequência de olhos, boca, cor, olhar e mãozinhas, como os gestos favoritos. Ex.: "desconfiado, olhando de lado".' },
{ emoji: '🤹', nome: 'Atividade', texto: 'O bonequinho faz algo com braços, pernas e objetos (bola, livro, notas, estrelas, halter, microfone, flor...). Ex.: "malabarismo com 3 bolinhas".' },
{ emoji: '🎨', nome: 'Figurinha', texto: 'Desenho que aparece nos olhos ou em volta do rosto.' },
{ emoji: '🧬', nome: 'Basear em', texto: 'Escolha um gesto, atividade ou criação que já existe e peça uma variação: a IA parte da receita dele.' },
{ emoji: '✨', nome: 'Gemini', texto: 'O melhor resultado e o mais rápido (alguns segundos). Usa a chave paga.' },
{ emoji: '🖥️', nome: 'IA local', texto: 'Um servidor na sua rede (LM Studio ou llama-server, como o ROG Ally). Grátis, mas pode levar alguns minutos e criar coisas mais simples. Configure em "IA local".' },
{ emoji: '📱', nome: 'Qwen do S10', texto: 'O modelo pequeno do celular. Rápido e grátis, mas fraco para criar: bom só para testar.' },
{ emoji: '⭐', nome: 'Favoritos', texto: 'A estrela põe expressões e atividades criadas na grade de favoritos do Ao vivo (e nas teclas 1 a 9).' },
],
},
personalidade: {
titulo: 'IA local (Qwen)',
emoji: '🧠',
onde: 'Ao vivo › Personalidade',
resumo: 'Com "Reagir sozinho com IA local" ligado, o Qwen escolhe uma expressão a partir dos sons e movimentos que o celular percebe, no máximo a cada 45 s. Cada evento só tem gestos que combinam (barulho: susto ou olhar para o lado; movimento: olhar e acenar) e ele nunca repete os dois últimos. Não interrompe atividades e, com o modo vida ligado, ficar sozinho é com o modo vida. Roda sem internet e sem custo.',
},
acontecimentos: {
titulo: 'Acontecimentos',
emoji: '🔔',
onde: 'Ao vivo › Acontecimentos',
resumo: 'Lista ao vivo do que acabou de acontecer: gestos, respostas da conversa, reações, detecções e o que o modo vida fez.',
itens: [
{ emoji: '🚶', nome: 'Sensor de presença (PIR)', texto: 'Mostra se o sensor está funcionando e quando viu alguém pela última vez.' },
{ emoji: '🗂️', nome: 'Eventos', texto: 'Abre o histórico completo com fotos.' },
],
},
eventos: {
titulo: 'Eventos e fotos',
emoji: '🗂️',
onde: 'Eventos',
resumo: 'Cada detecção vira um evento com horário e, se ligado, uma foto da câmera do celular. Os eventos ficam guardados pelos dias escolhidos (padrão 3) e depois são apagados sozinhos.',
itens: [
{ emoji: '🚶', nome: 'Sensor de presença (PIR)', texto: 'Sensor HC-SR505 no pino 11 da placa. Vê calor de corpo em movimento a até ~3 m.' },
{ emoji: '👀', nome: 'Movimento na câmera', texto: 'O celular percebe algo se mexendo na frente.' },
{ emoji: '🔊', nome: 'Som alto', texto: 'O celular ouve um barulho forte (só o nível, nunca o áudio).' },
{ emoji: '📷', nome: 'Guardar foto', texto: 'Tira uma foto em cada evento. "Ver foto" abre a imagem.' },
{ emoji: '⏱️', nome: 'Intervalo', texto: 'Tempo mínimo entre dois eventos do mesmo tipo, para não lotar o histórico.' },
],
},
presenca: {
titulo: 'Modo presença',
emoji: '🚶',
onde: 'Eventos › Modo presença',
resumo:
'O PIR liga a câmera do celular quando alguém aparece e acorda o Carinha. Sem ninguém pelo tempo escolhido, a câmera desliga, ele dorme e fica em silêncio. Na madrugada silenciosa o PIR não acorda.',
},
tela: {
titulo: 'Tela e personalidade',
emoji: '📺',
onde: 'Ajustes › Tela e personalidade',
resumo: 'Como o rosto aparece no celular e o que ele faz sozinho.',
itens: [
{ emoji: '🎲', nome: 'Gestos espontâneos', texto: 'Pisca e olha em volta sem comando. Com o modo vida, os gestos seguem o humor.' },
{ emoji: '👄', nome: 'Boca, sobrancelhas e íris', texto: 'Liga ou desliga cada parte do rosto.' },
{ emoji: '💡', nome: 'Brilho e repouso', texto: 'Brilho normal e o mais fraco depois de um tempo sem nada acontecer.' },
],
},
sensores: {
titulo: 'Sensores e reações',
emoji: '🎙️',
onde: 'Ajustes › Sensores e reações',
resumo: 'O que o celular usa para perceber o ambiente. Só os níveis são usados: nenhum áudio é gravado e a câmera não sai do celular sem você ligar a transmissão.',
itens: [
{ emoji: '🎙️', nome: 'Microfone / Reagir a sons', texto: 'Ouve o nível do som para se assustar e acordar.' },
{ emoji: '🤫', nome: 'Silêncio para dormir', texto: 'Quantos segundos sem som nem movimento até ele adormecer sozinho.' },
{ emoji: '📷', nome: 'Câmera como sensor / Reagir a movimento', texto: 'Detecta movimento na frente do celular.' },
{ emoji: '😴', nome: 'Microfone e câmera durante o sono', texto: 'Se continuam ligados dormindo (para poder acordar).' },
],
},
sensibilidade: {
titulo: 'Sensibilidade (avançado)',
emoji: '🎛️',
onde: 'Ajustes › Sensibilidade',
resumo: 'Os limiares de base dos sustos. O ritmo do modo vida multiplica esses valores (calmo sobe, agitado desce).',
itens: [
{ emoji: '🔉', nome: 'Nível para perceber som', texto: 'Acima disso conta como "tem som".' },
{ emoji: '💥', nome: 'Som mínimo e aumento súbito para susto', texto: 'Precisa passar dos dois: ser alto e ser bem mais alto que o barulho de fundo.' },
{ emoji: '🏃', nome: 'Movimento mínimo / para susto', texto: 'Quanto precisa mexer para ele olhar e para ele se assustar.' },
{ emoji: '⏳', nome: 'Intervalos', texto: 'Tempo mínimo entre dois sustos seguidos.' },
],
},
reacoes: {
titulo: 'Reações',
emoji: '✨',
onde: 'Reações',
resumo: 'Animações e figurinhas que aparecem nos olhos ou em volta do rosto.',
itens: [
{ emoji: '📚', nome: 'Biblioteca', texto: 'Toque para tocar no celular. Dá para curtir, editar, duplicar, desativar e apagar (com desfazer).' },
{ emoji: '🖌️', nome: 'Criar com IA', texto: 'Descreva uma figurinha e o Gemini desenha; ou deixe ele criar 3 reações a partir da conversa e da rotina.' },
{ emoji: '😉', nome: 'Gestos do rosto', texto: 'Todos os gestos, com a estrela para fixar nos favoritos.' },
{ emoji: '📖', nome: 'Diário', texto: 'Resumos e registros usados para criar reações.' },
],
},
conversa: {
titulo: 'Conversa',
emoji: '💬',
onde: 'Conversa',
resumo: 'Fale ou escreva; o Carinha responde em voz de robô, com legenda na tela do celular e um gesto.',
itens: [
{ emoji: '🤖', nome: 'Motor', texto: 'Qwen local (sem internet, sem custo) ou Gemini (responde melhor). Troca em Ajustes › Inteligência artificial.' },
{ emoji: '🧠', nome: 'Lembranças', texto: 'Coisas que ele guarda sobre você e usa nas respostas.' },
{ emoji: '🗓️', nome: 'Rotina e preferências', texto: 'Hábitos e gostos que você ensina; também inspiram as reações.' },
],
},
movimento: {
titulo: 'Movimento (braço)',
emoji: '🦾',
onde: 'Movimento',
resumo: 'Controle do braço de 7 articulações pela placa de servos (PCA9685).',
itens: [
{ emoji: '✋', nome: 'Segurar para mover', texto: 'O braço só se mexe enquanto você segura o botão ou o controle. Soltou, ele para.' },
{ emoji: '🧍', nome: 'Posturas e sequências', texto: 'Posições salvas e movimentos encadeados. A prévia anima só a tela.' },
{ emoji: '📏', nome: 'Calibração', texto: 'Limites de cada motor, testados passo a passo.' },
{ emoji: '🩺', nome: 'Diagnóstico', texto: 'Teste canal a canal com limites conservadores. Não abre junto com o controle do braço.' },
],
dica: 'O E-Stop vermelho no topo para tudo. Depois é preciso rearmar para mover de novo.',
},
atalhos: {
titulo: 'Atalhos de teclado',
emoji: '⌨️',
onde: 'Em qualquer tela',
resumo: 'Para usar o painel sem o mouse.',
itens: [
{ nome: 'Ctrl+K', texto: 'Busca de comandos (gestos, rostos, cores, telas, este guia).' },
{ nome: '1 a 9', texto: 'Gestos favoritos.' },
{ nome: 'Esc Esc ou Shift+Espaço', texto: 'E-Stop: para tudo.' },
{ nome: 'g e uma letra', texto: 'Vai para uma tela: g v Ao vivo, g e Eventos, g c Conversa, g r Reações, g m Movimento, g a Ajustes.' },
],
},
};
/** Ordem do tutorial (página Guia). */
export const ORDEM_GUIA = [
'inicio', 'vida', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'aparencia',
'personalidade', 'acontecimentos', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
'reacoes', 'conversa', 'movimento', 'atalhos',
];
/** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = {
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar',
};
+14 -1
View File
@@ -1,8 +1,9 @@
/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */
import {
cor, CORES, formato, FORMATOS_INFO, gesto, GESTOS_INFO, gestosDisponiveis, nomeRosto, pararTudo, rearmar, recarregarCelular, rosto,
atividade, ATIVIDADES_INFO, cor, CORES, formato, FORMATOS_INFO, gesto, GESTOS_INFO, gestosDisponiveis, nomeRosto, pararTudo, rearmar, recarregarCelular, rosto,
} from './comandos.svelte';
import { estado } from './estado.svelte';
import { ORDEM_GUIA, TOPICOS } from './guia';
import { nav, ROTAS } from './rotas.svelte';
export interface ItemPaleta {
@@ -36,6 +37,18 @@ export function itensDaPaleta(): ItemPaleta[] {
registrarNaPaleta(() => [
...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') },
{ id: 'ir-criar', grupo: 'Ir para', titulo: 'Criar com IA (expressões, atividades, figurinhas)', fazer: () => nav.ir('criar') },
...Object.entries(ATIVIDADES_INFO).map(([id, a]) => ({
id: 'atividade-' + id, grupo: 'Atividades', titulo: a.emoji + ' ' + a.nome, fazer: () => atividade(id),
})),
{ id: 'atividade-parar', grupo: 'Atividades', titulo: '⏹ Parar a atividade', fazer: () => atividade('parar') },
...ORDEM_GUIA.map((t) => ({
id: 'guia-' + t,
grupo: 'Guia',
titulo: TOPICOS[t].emoji + ' ' + TOPICOS[t].titulo,
dica: TOPICOS[t].onde,
fazer: () => (location.hash = '/guia?t=' + t),
})),
...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
estado.estop.ativo
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
+3 -1
View File
@@ -1,6 +1,6 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
@@ -14,6 +14,8 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
/** 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' },
{ id: 'guia', titulo: 'Guia', volta: 'ajustes' },
{ id: 'criar', titulo: 'Criar com IA', volta: 'reacoes' },
];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
+22
View File
@@ -82,4 +82,26 @@ export interface EstadoServidor {
treinamento?: { ocupado: boolean; erro: string };
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
vida?: Vida;
}
/** Modo vida (/vida/status e SSE 'vida'): necessidades de 0 a 100, 100 = satisfeito. */
export interface Vida {
config: { ligado: boolean; criar_emojis: boolean; max_emojis_dia: number; ritmo: 'calmo' | 'normal' | 'agitado'; noite: boolean };
necessidades: { energia: number; alegria: number; social: number; diversao: number };
humor: string;
emoji: string;
nome_humor: string;
dormindo: boolean;
nascimento: number;
idade_dias: number;
emojis_hoje: number;
ultimo_emoji: number | null;
/** 0 a 1: o quanto está emburrado (alegria baixa). */
zanga?: number;
/** Dormindo de cansaço: som, movimento e PIR não acordam. */
sono_profundo?: boolean;
/** '' quando pode inventar figurinha agora; senão, o motivo. */
pode_criar: string;
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade'; texto: string }[];
}
+37
View File
@@ -0,0 +1,37 @@
/** Modo vida: ajustes e cuidados. O estado chega pelo SSE 'vida' em estado.vida. */
import { postar } from './api';
import { avisos } from './avisos.svelte';
import { estado } from './estado.svelte';
import type { Vida } from './tipos';
export const NECESSIDADES: { chave: keyof Vida['necessidades']; nome: string; emoji: string; cor: string }[] = [
{ chave: 'energia', nome: 'Energia', emoji: '⚡', cor: '#ffcc00' },
{ chave: 'alegria', nome: 'Alegria', emoji: '😊', cor: '#ff2d55' },
{ chave: 'social', nome: 'Companhia', emoji: '💬', cor: '#0a84ff' },
{ chave: 'diversao', nome: 'Diversão', emoji: '🎈', cor: '#34c759' },
];
export const CUIDADOS = [
{ acao: 'carinho', nome: 'Carinho', emoji: '❤️' },
{ acao: 'brincar', nome: 'Brincar', emoji: '🎈' },
{ acao: 'alimentar', nome: 'Energia', emoji: '⚡' },
] as const;
export async function configurarVida(parcial: Partial<Vida['config']>) {
const antes = estado.vida;
if (antes) estado.vida = { ...antes, config: { ...antes.config, ...parcial } };
try {
estado.vida = await postar<Vida>('/vida/config', parcial);
} catch (e) {
estado.vida = antes;
avisos.erro(e);
}
}
export async function cuidar(acao: string) {
try {
estado.vida = await postar<Vida>('/vida/cuidar', { acao });
} catch (e) {
avisos.erro(e);
}
}
+10 -3
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { Download, KeyRound, RefreshCw, RotateCcw, Search, ShieldCheck, ShieldOff } from '@lucide/svelte';
import { BookOpen, Download, KeyRound, RefreshCw, RotateCcw, Search, ShieldCheck, ShieldOff } from '@lucide/svelte';
import { gravarToken, lerToken, postar } from '../lib/api';
import { GRUPOS, restaurarPadrao, salvarApp, type Campo } from '../lib/ajustes.svelte';
import { avisos, tentar } from '../lib/avisos.svelte';
import { recarregarCelular } from '../lib/comandos.svelte';
import Ajuda from '../lib/componentes/Ajuda.svelte';
import Card from '../lib/componentes/Card.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Slider from '../lib/componentes/Slider.svelte';
@@ -97,7 +98,7 @@
{#each grupos as g (g.titulo)}
<fieldset>
<legend>
<strong>{g.titulo}</strong>
<strong class="titulo-grupo">{g.titulo} <Ajuda topico={g.ajuda} /></strong>
<span class="fraco pequeno-texto">{g.descricao}</span>
</legend>
{#each g.campos as c (c.chave)}
@@ -137,7 +138,7 @@
</Card>
<div class="coluna">
<Card titulo="Inteligência artificial">
<Card titulo="Inteligência artificial" ajuda="conversa">
<div class="campo">
<span>Motor da conversa</span>
<div class="segmentado" role="radiogroup" aria-label="Motor da conversa">
@@ -223,6 +224,7 @@
<RefreshCw size={14} /> Recarregar tela do celular
</button>
<a class="btn pequeno" href="/app.apk" download><Download size={14} /> Baixar app Android</a>
<a class="btn pequeno" href="#/guia"><BookOpen size={14} /> Guia do painel</a>
</div>
</Card>
</div>
@@ -267,6 +269,11 @@
gap: var(--e-2);
align-content: start;
}
.titulo-grupo {
display: inline-flex;
align-items: center;
gap: 2px;
}
legend {
display: grid;
gap: 2px;
+86 -8
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import {
Brain, Camera, Download, Eye, Footprints, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
Brain, Camera, Download, Eye, Footprints, Gamepad2, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video, Wand2,
} from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api';
import { salvarApp } from '../lib/ajustes.svelte';
import { tentar } from '../lib/avisos.svelte';
import { FORMATOS_INFO, hex, nomeRosto, recarregarCelular, ROSTOS_INFO } from '../lib/comandos.svelte';
import { atividade, ATIVIDADES_INFO, FORMATOS_INFO, hex, nomeRosto, recarregarCelular, ROSTOS_INFO } from '../lib/comandos.svelte';
import Celular from '../lib/componentes/Celular.svelte';
import Gestos from '../lib/componentes/Gestos.svelte';
import Grupo from '../lib/componentes/Grupo.svelte';
@@ -14,6 +14,7 @@
import Linha from '../lib/componentes/Linha.svelte';
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import VidaCartao from '../lib/componentes/VidaCartao.svelte';
import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte';
import { VideoAoVivo } from '../lib/video.svelte';
@@ -25,6 +26,8 @@
// Palco: câmera grande e rosto no quadrinho do canto; tocar no quadrinho troca os dois.
let rostoGrande = $state(false);
// Atividade que o celular está fazendo agora (vem na telemetria).
const atividadeAgora = $derived((estado.app?.online && (estado.app.estado?.atividade as string | null)) || null);
onMount(() => {
video = new VideoAoVivo(elVideo);
@@ -48,6 +51,11 @@
});
const cfg = $derived(estado.appConfig);
// Pontinho do PIR sobre a imagem: verde pulsando nos 8 s depois de cada disparo.
// aceso enquanto o pino do sensor está alto; servidor antigo (sem `ativo`) cai nos 8 s após o disparo
const pirAtivo = $derived(
estado.pir?.ativo ?? (!!estado.pir?.ultimo && estado.agora - estado.pir.ultimo * 1000 < 8000),
);
const transmitindo = $derived(!!cfg?.preview);
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
@@ -70,6 +78,12 @@
<div class="ao-vivo">
<!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
<section class="palco" aria-label="Câmera e rosto">
{#if estado.pir?.disponivel}
<span class="ponto-pir" class:ativo={pirAtivo} title="Sensor de presença (PIR)" aria-live="polite">
<span class="luz" aria-hidden="true"></span>
PIR{pirAtivo ? ' · presença' : ''}
</span>
{/if}
<div class="quadro camera" class:mini={rostoGrande}>
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
@@ -111,12 +125,14 @@
</section>
<div class="lista">
<Grupo titulo="Celular">
<VidaCartao />
<Grupo titulo="Celular" ajuda="celular">
<Celular />
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
</Grupo>
<Grupo titulo="Câmera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
<Grupo titulo="Câmera" ajuda="camera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
<Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}>
{#snippet acessorio()}
<Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} />
@@ -147,14 +163,32 @@
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
</Grupo>
<Grupo titulo="Gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
<Grupo titulo="Gestos" ajuda="gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos />
</Linha>
<Linha
icone={Gamepad2}
cor="#30b0c7"
rotulo="Atividades"
detalhe={atividadeAgora ? `Agora: ${ATIVIDADES_INFO[atividadeAgora]?.emoji ?? ''} ${ATIVIDADES_INFO[atividadeAgora]?.nome ?? atividadeAgora}` : 'Um bonequinho dele faz algo embaixo dos olhos'}
>
<div class="atividades">
{#each Object.entries(ATIVIDADES_INFO) as [id, a] (id)}
<button class="btn pequeno" class:ativo={atividadeAgora === id} onclick={() => atividade(id)}>
<span aria-hidden="true">{a.emoji}</span> {a.nome}
</button>
{/each}
{#if atividadeAgora}
<button class="btn pequeno" onclick={() => atividade('parar')}>⏹ Parar</button>
{/if}
</div>
</Linha>
<Linha icone={Wand2} cor="#af52de" rotulo="Criar com IA" detalhe="Expressões, atividades e figurinhas novas" href="#/criar" />
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
</Grupo>
<Grupo titulo="Aparência">
<Grupo titulo="Aparência" ajuda="aparencia">
<Linha
icone={Palette}
cor="#ff9500"
@@ -168,7 +202,7 @@
</Linha>
</Grupo>
<Grupo titulo="Personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}>
<Grupo titulo="Personalidade" ajuda="personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}>
<Linha
icone={Brain}
cor="#5e5ce6"
@@ -181,7 +215,7 @@
</Linha>
</Grupo>
<Grupo titulo="Acontecimentos">
<Grupo titulo="Acontecimentos" ajuda="acontecimentos">
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
{#snippet acessorio()}<PresencaPir />{/snippet}
</Linha>
@@ -205,6 +239,15 @@
gap: var(--e-4);
align-items: start;
}
.atividades {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.atividades .btn.ativo {
border-color: var(--destaque);
background: var(--destaque-suave);
}
/* ---------- palco: sempre visível */
.palco {
position: sticky;
@@ -300,6 +343,41 @@
.espaco {
flex: 1;
}
.ponto-pir {
position: absolute;
z-index: 3;
left: var(--e-3);
top: var(--e-3);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 7px;
border-radius: var(--raio-pill);
background: rgb(0 0 0 / 0.55);
color: #c7cdd4;
font: 600 11px var(--fonte);
letter-spacing: 0.04em;
pointer-events: none;
transition: color var(--transicao);
}
.ponto-pir .luz {
width: 10px;
height: 10px;
border-radius: 50%;
background: #6b7280;
transition: background var(--transicao);
}
.ponto-pir.ativo {
color: #b8f5c9;
}
.ponto-pir.ativo .luz {
background: #34c759;
animation: pulsoPir 1s ease-in-out infinite;
}
@keyframes pulsoPir {
0%, 100% { box-shadow: 0 0 0 0 rgb(52 199 89 / 0.7); }
50% { box-shadow: 0 0 0 7px rgb(52 199 89 / 0); }
}
/* ---------- lista */
.lista {
display: grid;
+1 -1
View File
@@ -204,7 +204,7 @@
</div>
</Card>
<Card titulo="Memória" descricao="O que o Carinha leva em conta ao responder. São dados, nunca instruções.">
<Card titulo="Memória" ajuda="conversa" descricao="O que o Carinha leva em conta ao responder. São dados, nunca instruções.">
<Abas
rotulo="Memória"
bind:ativa={aba}
+377
View File
@@ -0,0 +1,377 @@
<script lang="ts">
import { Cpu, Settings2, Sparkles, Star, Trash2, Wand2 } from '@lucide/svelte';
import { onMount } from 'svelte';
import { favoritos } from '../lib/comandos.svelte';
import Card from '../lib/componentes/Card.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import { criacao, TIPOS, type IaCriacao, type TipoCriacao } from '../lib/criacao.svelte';
import { estado } from '../lib/estado.svelte';
// Estúdio: escolhe o que criar e com qual IA, escreve o pedido (pode partir de algo que já existe)
// e as criações ficam numa grade igual à dos favoritos.
onMount(() => criacao.carregar());
const st = $derived(criacao.status);
let tipo = $state<TipoCriacao>('expressao');
let ia = $state<IaCriacao>('gemini');
let pedido = $state('');
let base = $state('');
let filtro = $state<'todos' | TipoCriacao>('todos');
let configAberta = $state(false);
let url = $state('');
let modelo = $state('');
let chave = $state('');
$effect(() => {
// base só vale para o tipo atual
tipo;
base = '';
});
$effect(() => {
const l = st?.provedores.local;
if (l && !url && !modelo) {
url = l.url ?? '';
modelo = l.modelo ?? '';
}
});
const IAS: { id: IaCriacao; icone: string }[] = [
{ id: 'gemini', icone: '✨' },
{ id: 'local', icone: '🖥️' },
{ id: 's10', icone: '📱' },
];
const tipoInfo = $derived(TIPOS.find((t) => t.id === tipo)!);
const bases = $derived(
tipo === 'figurinha' || !st
? []
: [
...st.bases[tipo].map((b) => ({ id: b.id, rotulo: `${b.emoji} ${b.nome}` })),
...st.criacoes.filter((c) => c.tipo === tipo).map((c) => ({ id: `criado:${c.id}`, rotulo: `${c.emoji} ${c.nome} (criado)` })),
],
);
const lista = $derived((st?.criacoes ?? []).filter((c) => filtro === 'todos' || c.tipo === filtro));
const ocupado = $derived(st?.ocupado ?? null);
const segundos = $derived(ocupado ? Math.max(0, Math.round(estado.agora / 1000 - ocupado.desde)) : 0);
const provedor = $derived(st?.provedores[ia]);
async function criar(e: SubmitEvent) {
e.preventDefault();
if (await criacao.criar(tipo, pedido.trim(), ia, base)) pedido = '';
}
async function salvarLocal(e: SubmitEvent) {
e.preventDefault();
const dados: { url: string; modelo: string; chave?: string } = { url: url.trim(), modelo: modelo.trim() };
if (chave.trim()) dados.chave = chave.trim();
await criacao.configurar(dados);
chave = '';
}
const nomeTipo = (t: TipoCriacao) => TIPOS.find((x) => x.id === t)?.nome ?? t;
const podeFavoritar = (t: TipoCriacao) => t !== 'figurinha';
</script>
<div class="criar">
<div class="topo">
<Card titulo="Criar com IA" ajuda="criar" descricao="A IA monta a criação só com peças que o Carinha já sabe animar, usando exemplos do que já existe.">
<form class="form" onsubmit={criar}>
<div class="campo">
<span>O que criar</span>
<div class="segmentado" role="radiogroup" aria-label="O que criar">
{#each TIPOS as t (t.id)}
<button type="button" role="radio" aria-checked={tipo === t.id} onclick={() => (tipo = t.id)}>
<span aria-hidden="true">{t.emoji}</span> {t.nome}
</button>
{/each}
</div>
</div>
<div class="campo">
<span>Qual IA</span>
<div class="ias" role="radiogroup" aria-label="Qual IA">
{#each IAS as x (x.id)}
{@const p = st?.provedores[x.id]}
<button type="button" role="radio" class="ia" aria-checked={ia === x.id} class:indisponivel={p && !p.pronto} onclick={() => (ia = x.id)}>
<span class="ia-nome"><span aria-hidden="true">{x.icone}</span> {p?.nome ?? x.id}</span>
<span class="ia-detalhe">{p ? (p.pronto ? p.detalhe : p.motivo) : '…'}</span>
</button>
{/each}
</div>
</div>
<label class="campo">
<span>Pedido</span>
<textarea bind:value={pedido} maxlength="600" placeholder={tipoInfo.exemplo} required></textarea>
</label>
{#if bases.length}
<label class="campo">
<span>Basear em (opcional)</span>
<select bind:value={base}>
<option value="">Nada: criar do zero (com exemplos do que existe)</option>
{#each bases as b (b.id)}<option value={b.id}>{b.rotulo}</option>{/each}
</select>
</label>
{/if}
<div class="linha acoes">
<button class="btn primario" type="submit" disabled={!!ocupado || !pedido.trim() || !provedor?.pronto}>
<Wand2 size={16} /> {ocupado ? 'Criando…' : `Criar ${nomeTipo(tipo).toLowerCase()}`}
</button>
<button class="btn fantasma pequeno" type="button" onclick={() => (configAberta = !configAberta)}>
<Settings2 size={14} /> IA local
</button>
</div>
{#if ocupado}
<Selo tom="aviso">
{st?.provedores[ocupado.ia]?.nome} criando {nomeTipo(ocupado.tipo).toLowerCase()}… {segundos}s
{ocupado.ia !== 'gemini' ? '(IA local pode levar alguns minutos)' : ''}
</Selo>
{:else if st?.erro}
<p class="erro pequeno-texto">{st.erro}</p>
{/if}
</form>
{#if configAberta}
<form class="form local" onsubmit={salvarLocal}>
<p class="fraco pequeno-texto">
Servidor compatível com OpenAI na sua rede (LM Studio com "Serve on Local Network" ou llama-server), por exemplo o ROG Ally.
</p>
<label class="campo"><span>Endereço</span><input type="text" bind:value={url} placeholder="http://192.168.1.50:1234" /></label>
<label class="campo"><span>Modelo</span><input type="text" bind:value={modelo} placeholder="qwen3-coder-30b-a3b-instruct" /></label>
<label class="campo">
<span>Senha da API {st?.provedores.local.chave ? '(deixe vazio para manter)' : '(opcional)'}</span>
<input type="password" bind:value={chave} autocomplete="off" />
</label>
<div class="linha">
<button class="btn pequeno primario" type="submit">Salvar</button>
<button class="btn pequeno" type="button" onclick={() => criacao.testar('local')} disabled={!st?.provedores.local.url}><Cpu size={14} /> Testar</button>
</div>
</form>
{/if}
</Card>
<div class="previa"><RostoPrevia titulo="Prévia das criações" espelharSono={false} /></div>
</div>
<Card titulo="Criações" descricao="Toque para mostrar no celular. A estrela fixa expressões e atividades nos favoritos do Ao vivo.">
{#snippet acoes()}
<div class="segmentado pequeno" role="radiogroup" aria-label="Filtrar">
<button role="radio" aria-checked={filtro === 'todos'} onclick={() => (filtro = 'todos')}>Todas</button>
{#each TIPOS as t (t.id)}
<button role="radio" aria-checked={filtro === t.id} onclick={() => (filtro = t.id)}>{t.emoji}</button>
{/each}
</div>
{/snippet}
{#if !st}
<p class="fraco">Carregando…</p>
{:else if !lista.length}
<p class="fraco">Nada criado ainda. Escreva um pedido acima. <Sparkles size={14} /></p>
{:else}
<div class="grade">
{#each lista as c (c.id)}
{@const fav = favoritos.lista.includes(`criado:${c.id}`)}
<div class="item">
<button class="cartao" onclick={() => criacao.tocar(c.id)} title={c.pedido}>
<span class="emoji" aria-hidden="true">{c.emoji}</span>
<span class="nome">{c.nome}</span>
<span class="meta">{nomeTipo(c.tipo)} · {st.provedores[c.ia]?.nome ?? c.ia}</span>
</button>
{#if podeFavoritar(c.tipo)}
<button
class="canto estrela"
class:ativa={fav}
aria-pressed={fav}
aria-label={fav ? `Tirar ${c.nome} dos favoritos` : `Fixar ${c.nome} nos favoritos`}
onclick={() => favoritos.alternar(`criado:${c.id}`)}><Star size={14} /></button
>
{/if}
<button class="canto lixo" aria-label={`Apagar ${c.nome}`} onclick={() => criacao.apagar(c.id)}><Trash2 size={13} /></button>
</div>
{/each}
</div>
{/if}
</Card>
</div>
<style>
.criar {
display: grid;
gap: var(--e-4);
}
.topo {
display: grid;
gap: var(--e-4);
align-items: start;
}
@media (min-width: 1024px) {
.topo {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.previa {
position: sticky;
top: calc(var(--topo-altura) + var(--e-4));
}
}
.previa {
aspect-ratio: 16 / 9;
border-radius: var(--raio-lg);
overflow: hidden;
background: #000;
border: 1px solid var(--borda);
}
.form {
display: grid;
gap: var(--e-3);
}
.form.local {
margin-top: var(--e-3);
padding-top: var(--e-3);
border-top: 1px solid var(--borda);
}
textarea {
min-height: 84px;
resize: vertical;
}
.segmentado {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 2px;
border-radius: var(--raio-sm);
background: var(--superficie-3);
}
.segmentado button {
flex: 1;
min-height: 34px;
padding: 0 var(--e-3);
border: 0;
border-radius: calc(var(--raio-sm) - 2px);
background: transparent;
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
white-space: nowrap;
}
.segmentado.pequeno button {
min-height: 28px;
padding: 0 var(--e-2);
font-size: var(--t-xs);
}
.segmentado button[aria-checked='true'] {
background: var(--superficie);
color: var(--texto);
box-shadow: var(--sombra);
}
.ias {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--e-2);
}
.ia {
display: grid;
gap: 2px;
padding: var(--e-2) var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--texto);
font: inherit;
text-align: left;
cursor: pointer;
}
.ia[aria-checked='true'] {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.ia.indisponivel .ia-detalhe {
color: var(--aviso);
}
.ia-nome {
font-weight: 600;
font-size: var(--t-sm);
}
.ia-detalhe {
font-size: var(--t-xs);
color: var(--texto-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acoes {
justify-content: space-between;
}
.erro {
color: var(--perigo);
}
.grade {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--e-2);
}
.item {
position: relative;
}
.cartao {
width: 100%;
display: grid;
justify-items: center;
gap: 2px;
padding: var(--e-3) var(--e-2) var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--texto);
font: inherit;
cursor: pointer;
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
}
.cartao:hover {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.cartao:active {
transform: scale(0.97);
}
.emoji {
font-size: 1.7rem;
line-height: 1.2;
}
.nome {
font-size: var(--t-sm);
text-align: center;
overflow-wrap: anywhere;
}
.meta {
font-size: 10px;
color: var(--texto-3);
text-align: center;
}
.canto {
position: absolute;
top: 2px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--texto-3);
cursor: pointer;
}
.estrela {
right: 2px;
}
.lixo {
left: 2px;
}
.lixo:hover {
color: var(--perigo);
}
.estrela.ativa {
color: var(--aviso);
}
.estrela.ativa :global(svg) {
fill: currentColor;
}
</style>
+2 -1
View File
@@ -116,6 +116,7 @@
<aside class="lado">
<Grupo
titulo="Modo presença"
ajuda="presenca"
rodape="Alguém aparece: o PIR liga a câmera do celular e acorda o Carinha. Ninguém pelo tempo escolhido: a câmera desliga, ele dorme e fica em silêncio."
>
<Linha
@@ -133,7 +134,7 @@
</Linha>
</Grupo>
<Grupo titulo="O que registrar">
<Grupo titulo="O que registrar" ajuda="eventos">
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)" detalhe={st?.pir.disponivel ? 'Pino 11 · HC-SR505' : st?.pir.erro || 'indisponível'}>
{#snippet acessorio()}
<Interruptor rotulo="Sensor de presença (PIR)" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
+143
View File
@@ -0,0 +1,143 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import TopicoGuia from '../lib/componentes/TopicoGuia.svelte';
import { ORDEM_GUIA, TOPICOS } from '../lib/guia';
// Guia completo: todos os tópicos em ordem de tutorial, com índice. #/guia?t=vida abre no tópico.
let busca = $state('');
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const lista = $derived(
ORDEM_GUIA.filter((id) => {
if (!busca) return true;
const t = TOPICOS[id];
const texto = [t.titulo, t.resumo, t.onde, t.dica ?? '', ...(t.itens ?? []).map((i) => i.nome + ' ' + i.texto)].join(' ');
return normal(texto).includes(normal(busca));
}),
);
async function irPara(id: string) {
await tick();
document.getElementById('guia-' + id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Abre no tópico do endereço, também quando o ⓘ é usado com o guia já aberto.
function pelaUrl() {
const t = new URLSearchParams(location.hash.split('?')[1] ?? '').get('t');
if (t && TOPICOS[t]) irPara(t);
}
onMount(() => {
pelaUrl();
addEventListener('hashchange', pelaUrl);
return () => removeEventListener('hashchange', pelaUrl);
});
</script>
<div class="guia">
<aside class="indice">
<label class="busca">
<span class="sr">Buscar no guia</span>
<input type="search" bind:value={busca} placeholder="Buscar no guia (ex.: ritmo, PIR, dormir)" />
</label>
<nav aria-label="Tópicos do guia">
{#each lista as id (id)}
<a href={'#/guia?t=' + id} onclick={(e) => { e.preventDefault(); history.replaceState(null, '', '#/guia?t=' + id); irPara(id); }}>
<span aria-hidden="true">{TOPICOS[id].emoji}</span> {TOPICOS[id].titulo}
</a>
{/each}
</nav>
</aside>
<div class="topicos">
{#each lista as id (id)}
<section class="topico" id={'guia-' + id}>
<h2><span aria-hidden="true">{TOPICOS[id].emoji}</span> {TOPICOS[id].titulo}</h2>
<TopicoGuia topico={TOPICOS[id]} />
</section>
{:else}
<p class="vazio">Nada no guia com “{busca}”.</p>
{/each}
</div>
</div>
<style>
.guia {
display: grid;
gap: var(--e-5);
align-items: start;
}
@media (min-width: 1024px) {
.guia {
grid-template-columns: 260px minmax(0, 1fr);
}
.indice {
position: sticky;
top: calc(var(--topo-altura) + var(--e-4));
}
}
.indice {
display: grid;
gap: var(--e-3);
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
nav {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
@media (min-width: 1024px) {
nav {
display: grid;
gap: 2px;
}
}
nav a {
display: flex;
gap: var(--e-2);
align-items: center;
padding: var(--e-1) var(--e-3);
border-radius: var(--raio-pill);
background: var(--superficie);
border: 1px solid var(--borda);
color: var(--texto-2);
font-size: var(--t-sm);
text-decoration: none;
}
@media (min-width: 1024px) {
nav a {
border: 0;
background: transparent;
border-radius: var(--raio-sm);
}
}
nav a:hover {
background: var(--superficie-2);
color: var(--texto);
}
.topicos {
display: grid;
gap: var(--e-4);
min-width: 0;
}
.topico {
display: grid;
gap: var(--e-3);
padding: var(--e-5);
background: var(--superficie);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
scroll-margin-top: calc(var(--topo-altura) + var(--e-4));
}
h2 {
font-size: var(--t-lg);
text-wrap: balance;
}
.vazio {
color: var(--texto-3);
}
</style>
+6 -3
View File
@@ -171,7 +171,10 @@
{/if}
{:else if aba === 'criar'}
<div class="grade-criar">
<Card titulo="Desenhar figurinha" descricao="O Gemini desenha um SVG e o celular mostra assim que ficar pronto.">
<Card titulo="Estúdio de criação" ajuda="criar" descricao="Expressões, atividades e figurinhas novas, escolhendo a IA (Gemini, IA local ou Qwen do S10).">
<a class="btn primario" href="#/criar">Abrir o estúdio</a>
</Card>
<Card titulo="Desenhar figurinha" ajuda="reacoes" descricao="O Gemini desenha um SVG e o celular mostra assim que ficar pronto.">
<form class="grade" onsubmit={desenhar}>
<label class="campo">
<span>O que desenhar</span>
@@ -204,7 +207,7 @@
</Card>
</div>
{:else if aba === 'gestos'}
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (até 12; as teclas 1 a 9 valem para os nove primeiros).">
<Card titulo="Gestos do rosto" ajuda="gestos" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (até 12; as teclas 1 a 9 valem para os nove primeiros).">
<Gestos completo />
</Card>
{:else}
@@ -249,7 +252,7 @@
</div>
<aside class="lateral">
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" /></div>
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" espelharSono={false} /></div>
<p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p>
</aside>
</div>
+1 -1
View File
@@ -70,7 +70,7 @@
<section class="bancada" aria-label="Prévia">
<div class="celular">
<div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" /></div>
<div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" espelharSono={false} /></div>
<span class="camera-frontal" aria-hidden="true"></span>
<span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span>
</div>
+1 -1
View File
@@ -125,7 +125,7 @@
</Card>
<div class="coluna">
<Card titulo="Mover">
<Card titulo="Mover" ajuda="movimento">
<Toggle
rotulo="Ativar movimento físico"
ajuda={!st?.disponivel ? 'PCA9685 indisponível neste servidor' : estado.estop.ativo ? 'Rearme o E-Stop primeiro' : 'Com isso desligado, tudo acontece só na tela'}