Compare commits

...
Author SHA1 Message Date
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
julioandClaude Opus 5.5 499a4ae479 feat(eventos): menu Eventos, modo presenca e 3 dias de historico
- "Eventos" vira item do menu principal: lista por dia (Hoje, Ontem...),
  presenca e movimento em destaque, botao "Ver foto" com miniatura
- modo presenca: o PIR liga a camera do celular e acorda o Carinha; sem
  ninguem por N minutos (5 por padrao) a camera desliga, ele dorme e fica
  em silencio (sons e fala desligados enquanto dorme)
- historico guarda so os ultimos 3 dias (ajustavel de 1 a 30), com faxina
  automatica de eventos e fotos antigos
- testes da retencao e do modo presenca

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:51:49 -03:00
julioandClaude Opus 5.5 6029c79645 feat(ao-vivo): indicador ao vivo do sensor PIR
Cada disparo do PIR vira um evento SSE 'pir' (mesmo dentro do intervalo do
historico) e o painel mostra 'presenca agora' por 8 s em Ao vivo e no
Historico; /estado inclui o estado do PIR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:47:11 -03:00
julioandClaude Opus 5.5 4b9e805801 fix(historico): ignora a borda falsa do PIR ao iniciar a leitura do pino
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:43:09 -03:00
julioandClaude Opus 5.5 1445ffdcde fix(historico): pull-down no pino do PIR evita disparos falsos com o sensor desligado
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:42:10 -03:00
julioandClaude Opus 5.5 788215b49e feat(historico): deteccoes com foto, sensor PIR na placa e tela apagando no sono
- historico.py: eventos de PIR (gpiomon no pino 11, gpiochip1 linha 70),
  movimento e som do celular em SQLite; ate 200 eventos e 100 fotos;
  cada sensor e a foto ligam/desligam; intervalo minimo por tipo
- PIR acorda o Carinha quando ele esta dormindo
- APK 0.8.3: o app consulta o pedido de foto e envia uma foto da camera
  frontal (quadro da camera de movimento ou do video ao vivo)
- tela apaga depois de X minutos dormindo (preto total + brilho minimo no
  AMOLED); acende com som, movimento, toque, comando ou PIR
- painel: tela Historico (sensores, fotos, filtros, apagar), atalho em Ao
  vivo e no Ctrl+K, ajustes "Apagar a tela quando dormir"
- testes do historico (servico e rotas HTTP)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:40:13 -03:00
julioandClaude Opus 5.5 98c9e8803e fix(rosto): expressoes e reacoes mantem a cor escolhida
Surpreso, triste e dormindo usavam azuis fixos; agora usam tons da cor
escolhida (mais clara no susto, mais escura e apagada na tristeza e no
sono). Nas reacoes da biblioteca o ciano padrao vira a cor escolhida e o
azul-claro um tom claro dela, no celular e nas miniaturas do painel.
Bravo (vermelho) e coracao (rosa) continuam, pois fazem parte da emocao.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:26:45 -03:00
julioandClaude Opus 5.5 2dab45604c feat(rosto): dormindo com olhos fechados e Zzz subindo
Dormindo, os rostos novos mostram os olhos como tracos (-) e tres Z de
tamanhos diferentes saem do canto do olho direito, sobem crescendo e somem
em ciclo. Na Matriz a boca vira '_' (- _ -) e os Z sao maiores, em LEDs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:19:58 -03:00
julioandClaude Opus 5.5 982bf56cd0 feat(rosto): sobrancelha perto do olho, boca mais alta e ajustes de posicao
A sobrancelha passa a ser posicionada a partir do topo real do olho de cada
estilo e formato (antes usava a altura do olho classico, alto demais nos
rostos novos). Nos rostos novos a boca sobe um pouco. Novos ajustes
'Subir a boca' e 'Distancia das sobrancelhas' (tela Rosto e Ajustes),
validados no servidor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:18:17 -03:00
julioandClaude Opus 5.5 1029b5fa99 feat(rosto): iris, boca e sobrancelhas na Matriz; emocoes da tela Rosto vao ao celular
- opcao "iris" (app config, Ajustes e tela Rosto): anel mais escuro com
  pupila que acompanha o olhar, dilata na alegria e contrai no susto; na
  Matriz vira LEDs apagados
- Matriz desenha boca e sobrancelhas em LEDs (antes a tela de pontos
  cobria o SVG); Bloco e Lente passam a obedecer o ajuste de boca
- tela Rosto: boca, sobrancelhas e iris ali mesmo; as teclas de emocao
  agora vao ao celular por padrao ("testar so na previa" e opcional)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:15:39 -03:00
julioandClaude Opus 5.5 a67c5ea9af fix(android): transmissao de video nao trava quando o servidor reinicia
Se o servidor caia no meio da transmissao, o app tentava reabrir a camera
ainda presa pelo sensor de movimento; o getUserMedia ficava esperando para
sempre e a fila de pedidos do painel travava (video "desligado" ate
reiniciar o app). Agora abrir a camera tem prazo de 8 s, cada pedido do
painel tem prazo de 12 s e uma camera encerrada de repente desfaz a sessao
para a proxima mensagem reabrir. Validado no S10: servidor reiniciado com
video ativo volta sozinho em ~8 s. APK 0.8.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:10:39 -03:00
julioandClaude Opus 5.5 e0dd03411d fix(voz): tira o timbre de sapo; voz aguda, limpa e com brilho metalico
O coaxar vinha do zumbido de 72 Hz, da ressonancia grave (~230 Hz), da
oitava de baixo e do FM desalinhado. Agora: ressonancia de lata fina
(~850 Hz), FM harmonico leve, realce em 3,2 kHz, oitava de baixo quase
nula e tom 25% acima. Bravo vira bipes zangados e pensar vira 'hmm?' com
bipinhos; nenhum som tem energia relevante abaixo de 250 Hz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:02:14 -03:00
julioandClaude Opus 5.5 74ae2863a7 feat(voz): timbre mais robotico e metalico
Modulacao em anel (72 Hz), ressonancia de lata (atraso de 4,3 ms
realimentado), degraus digitais com saturacao suave e FM com razao 1,41
nas silabas. Volume geral ajustado para compensar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:59:25 -03:00
julioandClaude Opus 5.5 f7c4793dc2 feat(voz): voz de robo sintetizada no estilo dos robozinhos de cinema
- voz-robo.js: silabas com deslize de tom, vibrato e filtro de formante
  ("uou", "ohh"), motorzinho de ruido filtrado e cliques; tudo gerado na
  hora com Web Audio, sem gravacoes nem arquivos de terceiros
- um som proprio para cada gesto, incluindo maos (motor ao subir e descer)
- balbucio de robo na resposta da conversa (mesma frase, mesmo som; sobe
  no fim quando e pergunta) e silencio dos gestos enquanto ele "fala"
- tela Rosto: ouvir a voz no PC e botao "Testar fala"; bipes antigos
  ficam como reserva

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:57:32 -03:00
60 changed files with 8040 additions and 185 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
+3
View File
@@ -23,3 +23,6 @@ api-privada.tmp
ia-config.json ia-config.json
seguranca.json seguranca.json
historico.db
historico.json
fotos/
+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto" package="br.carinha.rosto"
android:versionCode="9" android:versionCode="11"
android:versionName="0.8.1"> android:versionName="0.8.3">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" /> <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
+410 -46
View File
@@ -5,7 +5,13 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<style> <style>
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000} 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} .corpo{fill-rule:evenodd}
.traco{fill:none;stroke-linecap:round} .traco{fill:none;stroke-linecap:round}
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)} .corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
@@ -26,6 +32,7 @@
</svg> </svg>
<div id="fps"></div> <div id="fps"></div>
<script src="rosto-motor.js"></script> <script src="rosto-motor.js"></script>
<script src="voz-robo.js"></script>
<script> <script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao /* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */ próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
@@ -91,12 +98,33 @@ addEventListener("resize", ajustar);
const PRETO = [6, 8, 14]; const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255]; const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false }; const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
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;
if (apagar !== SONO.apagada) {
SONO.apagada = apagar;
window.carinhaTelaApagada = apagar;
document.body.style.visibility = apagar ? "hidden" : "";
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
}
}
setInterval(cuidarDaTela, 1000);
const CIANO_E = [150, 232, 255]; const CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78]; const VERM = [255, 78, 78];
const ROSA = [255, 112, 176]; const ROSA = [255, 112, 176];
const AMARELO = [255, 218, 96]; const AMARELO = [255, 218, 96];
const AZUL = [130, 170, 255]; const AZUL = [130, 170, 255];
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
const VERDE = [120, 255, 160]; const VERDE = [120, 255, 160];
const BRANCO = [245, 250, 255]; const BRANCO = [245, 250, 255];
@@ -149,8 +177,18 @@ function caminhoLinha(x0, y0, x1, y1) {
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1; return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
} }
/* formas da boca (o humor escolhe qual) */ /* formas da boca (o humor escolhe qual) */
function deslocamentoBoca() {
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
}
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
function metadeOlho(esc) {
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
return m * RY * esc;
}
function caminhoBoca(tipo) { function caminhoBoca(tipo) {
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2; const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
if (tipo === "aberta") { // surpresa: um "O" if (tipo === "aberta") { // surpresa: um "O"
const rx = l * 0.17, ry = l * 0.22; const rx = l * 0.17, ry = l * 0.22;
return caminhoElipse(cx, cy, rx, ry); return caminhoElipse(cx, cy, rx, ry);
@@ -171,8 +209,25 @@ function caminhoBoca(tipo) {
return "M " + (cx - meia) + "," + (cy - l * 0.10) + return "M " + (cx - meia) + "," + (cy - l * 0.10) +
" Q " + cx + "," + (cy + l * 0.42) + " " + (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 // repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0"; 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 // ------------------------------------------------------------------ desenho
@@ -224,6 +279,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.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(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) { if (icone) {
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||''; forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy); p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
@@ -291,7 +353,7 @@ function sequencias() {
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]]; const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18], const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]]; [{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09], const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
[{ dy: -10 }, 0.5], [{ dy: -10 }, 0.5],
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]]; [{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1], const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
@@ -300,7 +362,7 @@ function sequencias() {
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15], const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15], [{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
[{ forma: "aberto", cor: CIANO }, 0.3]]; [{ forma: "aberto", cor: CIANO }, 0.3]];
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5], const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]]; [{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5], const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]]; [{ forma2: "aberto", esc: 1.0 }, 0.18]];
@@ -313,7 +375,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]]; [{ 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], const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]]; [{ 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, piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre }; bravo, coracao, triste, piscadinha, alegre };
} }
@@ -327,12 +405,92 @@ const r = {
const SEQS = sequencias(); const SEQS = sequencias();
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo", const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"]; "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() { function proximo() {
if (!r.fila.length) { r.kf = null; return; } if (!r.fila.length) { r.kf = null; return; }
const [alvo, dur] = r.fila[0]; const [alvo, dur] = r.fila[0];
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy }; r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
r.cor_ini = r.cor.slice(); r.cor_ini = r.cor.slice();
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
if ("forma" in alvo) r.forma = alvo.forma; if ("forma" in alvo) r.forma = alvo.forma;
if ("forma2" in alvo) r.forma2 = alvo.forma2; if ("forma2" in alvo) r.forma2 = alvo.forma2;
if ("boca" in alvo) r.boca = alvo.boca; if ("boca" in alvo) r.boca = alvo.boca;
@@ -340,7 +498,22 @@ function proximo() {
r.kf_t = 0; 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) { 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]) { if (SEQS[nome]) {
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]); r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
proximo(); proximo();
@@ -364,10 +537,40 @@ function passo(dt) { // avanca a fila; true se ha gesto em
return true; 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" function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) { if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono"; r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2)); r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
r.ab = 0; r.ab2 = 0; r.ab = 0; r.ab2 = 0;
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4); r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0; r.dx = 0;
@@ -378,9 +581,28 @@ function repouso(t) { // pose calma: olha devagar, "respira
r.boca = "neutra"; r.boca = "neutra";
r.cor = CIANO.slice(); r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0; 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) // olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX; // centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY; 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() { function desenhar() {
@@ -392,7 +614,7 @@ function desenhar() {
// desliza na direcao do movimento; parado, volta ao repouso discreto // desliza na direcao do movimento; parado, volta ao repouso discreto
const tAgora = performance.now() / 1000; const tAgora = performance.now() / 1000;
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo; 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 mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy; const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
@@ -400,9 +622,11 @@ function desenhar() {
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; }); OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) { if (comMotor) {
window.MotorRosto.desenhar({ 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, icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null, carga: window.carinhaBateria || null,
iris: EXTRAS.iris,
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null, desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
parado: !r.kf && !seguindo && !r.dormindo, parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({ olhos: olhos.map(([c, abx, fx, lado]) => ({
@@ -416,24 +640,35 @@ function desenhar() {
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc, olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6); abx, fx, r.cor, lado, dx, dy * 0.6);
} }
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none"; const matriz = comMotor && ROSTO.motor === "matriz";
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
else desenharBoca(); else desenharBoca();
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1)); const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none'; brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue; if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2; brow.setAttribute('d', caminhoSobrancelha(i, esc));
const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85;
const y = c[1] - RY * esc - 20 * 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('stroke', rgb(r.cor)); brow.setAttribute('stroke', rgb(r.cor));
brow.setAttribute('stroke-width', 7 * ESC); brow.setAttribute('stroke-width', 7 * ESC);
} }
} }
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
function tracosDoRosto(esc) {
const tracos = [];
if (EXTRAS.boca) {
const forma = r.boca;
if (r.dormindo) { // dormindo na Matriz: "– _ –"
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
}
if (EXTRAS.sobrancelhas && !r.dormindo) {
for (let i = 0; i < 2; i++) tracos.push({ d: caminhoSobrancelha(i, esc), largura: 9 * ESC });
}
return tracos;
}
// ------------------------------------------------------------------ controle // ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */ /* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) { function trocarRosto(nome) {
@@ -470,8 +705,25 @@ function trata(ordem) {
if (v.length === 3 && v.every((n) => !isNaN(n))) { if (v.length === 3 && v.every((n) => !isNaN(n))) {
CIANO.splice(0, 3, ...v); r.cor = v; 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") { } else if (ordem === "dormir" || ordem === "sono") {
r.dormindo = true; r.dormindo = true;
atividade(null);
} else if (ordem === "acordar") { } else if (ordem === "acordar") {
r.dormindo = false; animar("surpreso"); r.dormindo = false; animar("surpreso");
} else if (ordem === "aleatorio") { } else if (ordem === "aleatorio") {
@@ -479,12 +731,12 @@ function trata(ordem) {
} else if (SEQS[ordem]) { } else if (SEQS[ordem]) {
if (window.MotorRosto) { if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 }); if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
} }
animar(ordem); animar(ordem);
} else if (ordem === "calma") { } else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro. // E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false; r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
atividade(null);
} else if (ordem === "parar") { } else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; } try { window.close(); } catch (e) { r.fila = []; }
} }
@@ -501,7 +753,7 @@ function pose(nome) {
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) { for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k]; if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
} }
if ("cor" in alvo) r.cor = alvo.cor.slice(); if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
congelado = true; congelado = true;
desenhar(); desenhar();
return true; return true;
@@ -556,6 +808,7 @@ if (MODO_PAINEL) {
const m = e.data; const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem); 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 === '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 (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); } else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
@@ -569,9 +822,11 @@ if (MODO_PAINEL) {
if (!inicio) return; if (!inicio) return;
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t; const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
inicio = null; inicio = null;
if (r.dormindo) { trata('acordar'); return; } if (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; }
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); 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) { if (dur < 350 && Math.hypot(dx, dy) < 15) {
cuidar('cutucar');
animar('surpreso'); animar('surpreso');
setTimeout(() => animar('feliz'), 700); setTimeout(() => animar('feliz'), 700);
} }
@@ -617,6 +872,49 @@ const MIC = {
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos 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) { function som(nivel) {
const n = Math.max(0, Math.min(1, Number(nivel) || 0)); const n = Math.max(0, Math.min(1, Number(nivel) || 0));
const agora = performance.now() / 1000; const agora = performance.now() / 1000;
@@ -631,8 +929,8 @@ function som(nivel) {
if (MIC.piso === null) MIC.piso = n; if (MIC.piso === null) MIC.piso = n;
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015); else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
const temSom = n >= MIC.limiarSom; // ruido audivel (absoluto) const temSom = n >= limiar(MIC.limiarSom); // ruido audivel (absoluto)
const alto = n >= MIC.limiarAlto && (n - MIC.piso) >= MIC.ganho; const alto = n >= limiar(MIC.limiarAlto) && (n - MIC.piso) >= limiar(MIC.ganho);
if (temSom) MIC.ultimoSom = agora; if (temSom) MIC.ultimoSom = agora;
// registro periodico (aparece no logcat do app: prova que o ouvido chegou) // registro periodico (aparece no logcat do app: prova que o ouvido chegou)
@@ -641,15 +939,19 @@ function som(nivel) {
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2)); 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; r.dormindo = false;
animar("surpreso"); animar("surpreso");
MIC.proxSurpresa = agora + MIC.cooldown; MIC.proxSurpresa = agora + MIC.cooldown;
console.log("ouvido: acordou nivel=" + n.toFixed(2)); console.log("ouvido: acordou nivel=" + n.toFixed(2));
relatarSom("acordou", n); relatarSom("acordou", n);
} else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) { }
MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino } else if (contar(CONTA.somReagir, PERFIL.qualquer ? temSom : alto, agora, PERFIL.reagir, "ouvido: estimulo") &&
animar("surpreso"); 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)); console.log("ouvido: surpreso nivel=" + n.toFixed(2));
relatarSom("surpreso", n); relatarSom("surpreso", n);
} }
@@ -662,6 +964,7 @@ function checarSilencio(agora) {
if (!MIC.ativo) return; // telinha do Pi: sem ouvido if (!MIC.ativo) return; // telinha do Pi: sem ouvido
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
if (r.dormindo) return; 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 // movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0); const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
if (agora - atividade >= MIC.segSilencio) { if (agora - atividade >= MIC.segSilencio) {
@@ -675,6 +978,25 @@ function checarSilencio(agora) {
journal do carinha-web (so quando o microfone esta ativo). */ journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) { function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
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;
try {
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
} catch (e) {}
} }
// ------------------------------------------------------ presenca (movimento) // ------------------------------------------------------ presenca (movimento)
@@ -714,7 +1036,7 @@ function rastrear(t) {
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65; MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
if(t.atividade===true) { if(t.atividade===true) {
MOV.ultimoMov=agora; 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) { function movimento(vel, cx, cy) {
@@ -728,7 +1050,7 @@ function movimento(vel, cx, cy) {
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; } if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
if (TRACK.alvo && agora-TRACK.visto<.35) 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; if (mexeu) MOV.ultimoMov = agora;
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro // olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
@@ -743,17 +1065,21 @@ function movimento(vel, cx, cy) {
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2)); MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
} }
const forte = v >= MOV.limiarForte; const forte = v >= limiar(MOV.limiarForte);
if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente 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; r.dormindo = false;
animar("surpreso"); animar("surpreso");
MOV.proxSurpresa = agora + MOV.cooldown; MOV.proxSurpresa = agora + MOV.cooldown;
console.log("presenca: acordou nivel=" + v.toFixed(2)); console.log("presenca: acordou nivel=" + v.toFixed(2));
relatarMov("acordou", v); 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 // susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
MOV.proxSurpresa = agora + MOV.cooldown; MOV.proxSurpresa = agora + MOV.cooldown * PERFIL.espera;
animar("surpreso"); animar(reacaoEstimulo());
console.log("presenca: surpreso nivel=" + v.toFixed(2)); console.log("presenca: surpreso nivel=" + v.toFixed(2));
relatarMov("surpreso", v); relatarMov("surpreso", v);
} }
@@ -763,6 +1089,7 @@ function movimento(vel, cx, cy) {
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */ /* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) { function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
registrarEvento("movimento", evento, n);
} }
// ------------------------------------------------- camera oculta (sensor) // ------------------------------------------------- camera oculta (sensor)
@@ -893,7 +1220,15 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null; let configRecebida = null;
function configurarApp(c) { function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; 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; AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume; AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir; COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
@@ -923,6 +1258,8 @@ async function sincronizarApp() {
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2, movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida, centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho, brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
atividade: window.MotorRosto && window.MotorRosto.atividadeAtual ? window.MotorRosto.atividadeAtual() : null,
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho, aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
@@ -969,15 +1306,32 @@ const SONS = {
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); }, dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
}; };
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
os sons dos gestos ficam quietos para não embolar. */
function falar(texto) {
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
const c = contextoAudio();
if (!c) return;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try {
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
AUDIO.mudoAte = performance.now() + dur * 1000;
} catch (e) { /* segue sem voz */ }
}
function tocarSom(gesto) { function tocarSom(gesto) {
if (!AUDIO.ligado) return false; if (!AUDIO.ligado) return false;
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
const f = SONS[gesto]; const f = SONS[gesto];
if (!f) return false; if (!voz && !f) return false;
const c = contextoAudio(); const c = contextoAudio();
if (!c) return false; if (!c) return false;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } } if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try { try {
f(c, c.currentTime + 0.02, AUDIO.volume); if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo // aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
console.log("voz: " + gesto + " ctx=" + c.state); console.log("voz: " + gesto + " ctx=" + c.state);
return true; return true;
@@ -1003,6 +1357,11 @@ function laco(t) {
if (window.innerWidth !== W || window.innerHeight !== H) ajustar(); if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
checarSilencio(t / 1000); 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);
const ocupado = congelado ? true : passo(dt); const ocupado = congelado ? true : passo(dt);
if (!ocupado) { if (!ocupado) {
@@ -1013,15 +1372,18 @@ function laco(t) {
} else if (t >= proxPiscar) { } else if (t >= proxPiscar) {
animar("piscar"); animar("piscar");
proxPiscar = t + 1600 + Math.random() * 3500; proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto) { } else if (AUTO && t >= proxGesto && !ATIV) {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); animar(gestoSolto());
proxGesto = t + 7000 + Math.random() * 8000; // 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) { } else if (t >= proxGesto) {
proxGesto = t + 7000 + Math.random() * 8000; proxGesto = t + 7000 + Math.random() * 8000;
} }
desenhar(); desenhar();
if (window.MotorRosto && window.MotorRosto.palco) window.MotorRosto.palco({ t: t / 1000, cor: CIANO });
contador++; contador++;
if (t - marco >= 1000) { if (t - marco >= 1000) {
@@ -1064,6 +1426,7 @@ setTimeout(relatar, 1500);
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return; try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;} const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id; if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
falar(m.texto);
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto); MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto}); if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
}catch(e){}finally{fetching=false;} }catch(e){}finally{fetching=false;}
@@ -1113,6 +1476,7 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
const m = s.ultima; const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return; if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id; ultimaConversa = m.id;
falar(m.texto);
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto); MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto }); if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
}); });
+921 -3
View File
@@ -107,7 +107,20 @@
} }
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes). // 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) { 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 respira = 1 + 0.012 * Math.sin(o.t * 1.4);
const temConteudo = !!(o.icone || o.desenho); const temConteudo = !!(o.icone || o.desenho);
if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 }; if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 };
@@ -118,6 +131,11 @@
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6); const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
const x = p.cx - w / 2, y = p.cy - h / 2; const x = p.cx - w / 2, y = p.cy - h / 2;
c.fillStyle = css(o.cor); c.fillStyle = css(o.cor);
if (o.dormindo && !temConteudo) { // dormindo: "–" (olho fechado)
const esp = Math.max(2, p.ry * 0.2);
caixa(c, p.cx - w * 0.5, p.cy + h * 0.08 - esp / 2, w, esp, esp / 2); c.fill();
return;
}
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; } if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
caixa(c, x, y, w, h, est.r * p.ry * respira); caixa(c, x, y, w, h, est.r * p.ry * respira);
c.fill(); c.fill();
@@ -132,7 +150,21 @@
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill(); c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over'; c.globalCompositeOperation = 'source-over';
if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74); if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74);
else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar else if (o.iris && fecha < 0.6 && e.bot < 0.35) {
const R = Math.min(w, h) * 0.3;
const ix = Math.max(x + R * 1.1, Math.min(x + w - R * 1.1, p.cx + (p.lx + sacada.x) * w * 0.016));
const iy = Math.max(y + R * 1.1, Math.min(y + h - R * 1.1, p.cy + h * 0.04 + (p.ly + sacada.y) * h * 0.016));
c.fillStyle = css(o.cor.map((v) => v * 0.42));
c.beginPath(); c.arc(ix, iy, R, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'destination-out';
const pupila = p.ab > 1.05 ? 0.38 : e.bot > 0.2 ? 0.62 : 0.5; // susto contrai, alegria dilata
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over';
if (est.brilho) {
c.fillStyle = 'rgba(245,250,255,0.9)';
c.beginPath(); c.arc(ix - R * 0.42, iy - R * 0.42, R * 0.2, 0, Math.PI * 2); c.fill();
}
} else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012)); const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012));
const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012)); const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012));
c.fillStyle = 'rgba(245,250,255,0.9)'; c.fillStyle = 'rgba(245,250,255,0.9)';
@@ -143,6 +175,7 @@
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal. // Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) { 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 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))); const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
c.save(); c.save();
@@ -205,7 +238,15 @@
bctx.clearRect(0, 0, colunas, linhas); bctx.clearRect(0, 0, colunas, linhas);
bctx.save(); bctx.scale(f, f); bctx.save(); bctx.scale(f, f);
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
for (const traco of o.tracos || []) {
if (typeof traco.d !== 'string') continue;
const caminho = new Path2D(traco.d);
bctx.strokeStyle = bctx.fillStyle = css(o.cor);
bctx.lineWidth = traco.largura; bctx.lineCap = 'round'; bctx.lineJoin = 'round';
if (traco.cheio) bctx.fill(caminho); else bctx.stroke(caminho);
}
desenharMaos(bctx, o, olhos, tela.height / d); desenharMaos(bctx, o, olhos, tela.height / d);
desenharZzz(bctx, o, olhos, 1.6); // em LEDs o Z precisa ser maior para ler
bctx.restore(); bctx.restore();
const px = bctx.getImageData(0, 0, colunas, linhas).data; const px = bctx.getImageData(0, 0, colunas, linhas).data;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height); ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
@@ -245,10 +286,12 @@
} else if (estilo.lente) { } else if (estilo.lente) {
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
desenharMaos(ctx, o, olhos, tela.height); desenharMaos(ctx, o, olhos, tela.height);
desenharZzz(ctx, o, olhos);
} else { } else {
cctx.clearRect(0, 0, camada.width, camada.height); cctx.clearRect(0, 0, camada.width, camada.height);
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
desenharMaos(cctx, o, olhos, tela.height); desenharMaos(cctx, o, olhos, tela.height);
desenharZzz(cctx, o, olhos);
ctx.save(); ctx.save();
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); } if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
ctx.drawImage(camada, 0, 0); ctx.drawImage(camada, 0, 0);
@@ -260,7 +303,7 @@
// ------------------------------------------------------------------ mãos // ------------------------------------------------------------------ mãos
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências // 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. // 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 maos = { modo: null, t0: null };
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); }; 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 const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
@@ -358,6 +401,16 @@
const y = fora + (y0 - fora) * v; 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; 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); 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') { } else if (maos.modo === 'pensar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1); const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta); const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
@@ -367,6 +420,28 @@
} }
} }
// ------------------------------------------------------------------ Zzz
function desenharZzz(c, o, olhos, fator) {
fator = fator || 1;
if (!o.dormindo) return;
const d = olhos.slice().sort((a, b) => a.cx - b.cx)[1]; // olho da direita na tela
const x0 = d.cx + d.ry * 0.7, y0 = d.cy - d.ry * 0.55;
c.save();
c.strokeStyle = css(o.cor); c.lineCap = 'round'; c.lineJoin = 'round';
for (let i = 0; i < 3; i++) {
const k = ((o.t || 0) * 0.32 + i / 3) % 1; // cada Z num ponto do ciclo
const tam = d.ry * (0.28 + 0.3 * k) * fator;
const x = x0 + d.ry * 0.7 * k + Math.sin(k * Math.PI * 2) * d.ry * 0.08, y = y0 - d.ry * 1.7 * k;
c.globalAlpha = Math.min(1, k / 0.15) * (1 - k);
c.lineWidth = Math.max(1.5, tam * 0.2);
c.beginPath();
c.moveTo(x - tam / 2, y - tam / 2); c.lineTo(x + tam / 2, y - tam / 2);
c.lineTo(x - tam / 2, y + tam / 2); c.lineTo(x + tam / 2, y + tam / 2);
c.stroke();
}
c.restore();
}
// --------------------------------------------------------- faixa de baixo // --------------------------------------------------------- faixa de baixo
// A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga. // A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga.
const faixa = { modo: null, texto: '', t0: null, dur: 0 }; const faixa = { modo: null, texto: '', t0: null, dur: 0 };
@@ -467,10 +542,853 @@
c.restore(); 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; } function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = { window.MotorRosto = {
desenhar, retrato, esconder, desenhar, retrato, esconder,
/** Metade da altura do olho (em frações de RY) para o estilo/formato: posiciona as sobrancelhas. */
metadeAltura: (estilo, formato) => {
const base = ESTILOS[estilo]; if (!base) return 1;
if (base.lente) return base.w * 0.5 * 1.12;
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
},
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } }, 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 }])) };
})(); })();
+23 -3
View File
@@ -12,8 +12,13 @@ let config = {}, signature = '', movementEnabled = true, aviso = '', capacidades
let allowed = true; let allowed = true;
let lastMotionFrame = -1; let lastMotionFrame = -1;
function chave(c) { return JSON.stringify([c.cameraLado || 'user', c.videoResolucao || 'auto', c.videoFPS || 0]); } function chave(c) { return JSON.stringify([c.cameraLado || 'user', c.videoResolucao || 'auto', c.videoFPS || 0]); }
/* Nada pode esperar para sempre: se a câmera estiver presa (ex.: servidor caiu no meio e o
sensor de movimento ainda a segura), desiste e a próxima mensagem do painel tenta de novo. */
function comPrazo(promessa, ms, oque) {
return Promise.race([promessa, new Promise((_, rej) => setTimeout(() => rej(new Error(oque + ' demorou mais de ' + ms / 1000 + ' s')), ms))]);
}
async function capturar(c) { async function capturar(c) {
const media = await navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}}); const media = await comPrazo(navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}}), 8000, 'Abrir a câmera');
const track = media.getVideoTracks()[0]; const track = media.getVideoTracks()[0];
const facing = track.getSettings().facingMode; const facing = track.getSettings().facingMode;
if (facing && facing !== (c.cameraLado || 'user')) { if (facing && facing !== (c.cameraLado || 'user')) {
@@ -74,6 +79,12 @@ async function iniciar(id) {
const media = await capturar(config); signature = chave(config); const media = await capturar(config); signature = chave(config);
if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; } if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; }
stream = media; video.srcObject = stream; await video.play(); stream = media; video.srcObject = stream; await video.play();
// câmera fechada de repente (outro uso, erro do sistema): desfaz tudo; o próximo pacote reabre
stream.getVideoTracks()[0].onended = () => {
if (captureGeneration !== generation) return;
console.log('RTC câmera encerrada; recomeçando no próximo pedido');
parar(); sid = null;
};
pc = new RTCPeerConnection({iceServers:[]}); pc = new RTCPeerConnection({iceServers:[]});
sender = pc.addTrack(stream.getVideoTracks()[0], stream); sender = pc.addTrack(stream.getVideoTracks()[0], stream);
const mine = pc; const mine = pc;
@@ -133,14 +144,23 @@ async function receber(packet) {
} }
window.rtcReceber = packet => { window.rtcReceber = packet => {
allowed = true; allowed = true;
tasks = tasks.then(() => allowed ? receber(packet) : undefined).catch(e => { tasks = tasks.then(() => allowed ? comPrazo(receber(packet), 12000, 'Pedido de vídeo') : undefined).catch(e => {
console.log('RTC erro=' + e.name + ': ' + e.message); console.log('RTC erro=' + e.name + ': ' + e.message);
sinal({tipo:'erro',erro:e.name + ': ' + e.message}); sinal({tipo:'erro',erro:e.name + ': ' + e.message});
estado({tipo:'webrtc',estado:'erro',erro:e.message}); estado({tipo:'webrtc',estado:'erro',erro:e.message});
parar(); parar(); sid = null;
}); });
}; };
window.rtcParar = () => { allowed = false; parar(); sid = null; }; window.rtcParar = () => { allowed = false; parar(); sid = null; };
/* Foto do evento para o histórico, tirada do vídeo ao vivo (câmera de movimento parada). */
window.rtcFoto = () => {
if (!stream || video.readyState < 2 || !video.videoWidth) return;
const escala = Math.min(1, 1280 / video.videoWidth);
const tela = document.createElement('canvas');
tela.width = Math.round(video.videoWidth * escala); tela.height = Math.round(video.videoHeight * escala);
tela.getContext('2d').drawImage(video, 0, 0, tela.width, tela.height);
CarinhaRTC.foto(tela.toDataURL('image/jpeg', 0.82));
};
setInterval(() => { setInterval(() => {
if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return; if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return;
lastMotionFrame=video.currentTime; lastMotionFrame=video.currentTime;
+188
View File
@@ -0,0 +1,188 @@
/* voz-robo.js — voz sintetizada do Carinha, no estilo dos robozinhos de cinema.
*
* Tudo é gerado na hora com Web Audio: nenhuma gravação, nenhum arquivo de terceiros.
* Cada som é uma sequência de "sílabas": um oscilador que desliza de um tom a outro, com vibrato,
* passando por um filtro de formante (a "vogal": o→i faz o "uou"). Motores das mãos viram ruído
* filtrado, e a resposta da conversa vira um balbucio que sobe no fim quando é pergunta.
*/
(function () {
'use strict';
let ruido = null; // buffer de ruído reaproveitado
const TOM = 1.25; // todas as sílabas 25% acima do tom escrito
const saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
/*
* Cadeia de "brilho metálico" (criada uma vez por AudioContext):
* voz → ressonância de lata fina (agudo, ~850 Hz) → saturação leve → compressor → saída
* Nada grave: zumbido e ressonância graves deixavam a voz com cara de sapo.
*/
function saida(c) {
if (saidas.has(c)) return saidas.get(c);
const entrada = c.createGain();
const seca = c.createGain(); seca.gain.value = 0.8;
const atraso = c.createDelay(0.05); atraso.delayTime.value = 0.00118;
const volta = c.createGain(); volta.gain.value = 0.42;
const molhada = c.createGain(); molhada.gain.value = 0.35;
entrada.connect(seca);
entrada.connect(atraso); atraso.connect(volta); volta.connect(atraso); atraso.connect(molhada);
const satura = c.createWaveShaper();
const n = 2048, curva = new Float32Array(n);
for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; curva[i] = Math.tanh(x * 1.3); }
satura.curve = curva;
seca.connect(satura); molhada.connect(satura);
const brilho = c.createBiquadFilter(); // realce leve nos agudos: "metal" limpo
brilho.type = 'peaking'; brilho.frequency.value = 3200; brilho.Q.value = 1.2; brilho.gain.value = 4;
const comp = c.createDynamicsCompressor();
comp.threshold.value = -16; comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.15;
const passaBaixa = c.createBiquadFilter();
passaBaixa.type = 'lowpass'; passaBaixa.frequency.value = 8000;
const volume = c.createGain(); volume.gain.value = 0.8;
satura.connect(brilho); brilho.connect(comp); comp.connect(passaBaixa); passaBaixa.connect(volume); volume.connect(c.destination);
saidas.set(c, entrada);
return entrada;
}
/**
* Uma sílaba. de/para: tom em Hz; vogal: [formante inicial, final] em Hz;
* vib: [Hz, profundidade em Hz]; forma: tipo de onda.
*/
function silaba(c, t0, dur, de, para, vol, op) {
op = op || {};
vol *= 2.4; // compensa a energia que o filtro de formante tira
de *= TOM; para *= TOM; // voz de robozinho: aguda
const osc = c.createOscillator();
osc.type = op.forma || 'sawtooth';
osc.frequency.setValueAtTime(de, t0);
osc.frequency.exponentialRampToValueAtTime(Math.max(20, para), t0 + dur);
if (op.vib) {
const lfo = c.createOscillator(), prof = c.createGain();
lfo.frequency.value = op.vib[0]; prof.gain.value = op.vib[1];
lfo.connect(prof); prof.connect(osc.frequency);
lfo.start(t0); lfo.stop(t0 + dur + 0.05);
}
// FM harmônico e leve: brilho de metal sem aspereza
const mod = c.createOscillator(), modProf = c.createGain();
mod.type = 'sine';
mod.frequency.setValueAtTime(de * 2, t0);
mod.frequency.exponentialRampToValueAtTime(Math.max(20, para * 2), t0 + dur);
modProf.gain.setValueAtTime(de * (op.metal ?? 0.22), t0);
modProf.gain.exponentialRampToValueAtTime(Math.max(1, para * (op.metal ?? 0.22)), t0 + dur);
mod.connect(modProf); modProf.connect(osc.frequency);
mod.start(t0); mod.stop(t0 + dur + 0.05);
// corpo: uma senoide uma oitava abaixo deixa a voz menos "apito"
const sub = c.createOscillator();
sub.type = 'sine';
sub.frequency.setValueAtTime(de / 2, t0);
sub.frequency.exponentialRampToValueAtTime(Math.max(20, para / 2), t0 + dur);
const vogal = op.vogal || [900, 1500];
const filtro = c.createBiquadFilter();
filtro.type = 'bandpass'; filtro.Q.value = op.q || 5;
filtro.frequency.setValueAtTime(vogal[0], t0);
filtro.frequency.exponentialRampToValueAtTime(vogal[1], t0 + dur);
const g = c.createGain(), gs = c.createGain();
const ataque = Math.min(0.02, dur * 0.25);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + ataque);
g.gain.setValueAtTime(vol, t0 + dur * 0.7);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
gs.gain.value = 0.12; // pouca oitava de baixo: grave demais vira coaxar
osc.connect(filtro); sub.connect(gs); gs.connect(filtro); filtro.connect(g); g.connect(saida(c));
osc.start(t0); sub.start(t0);
osc.stop(t0 + dur + 0.05); sub.stop(t0 + dur + 0.05);
}
function bufferRuido(c) {
if (ruido && ruido.sampleRate === c.sampleRate) return ruido;
ruido = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = ruido.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return ruido;
}
/** Motorzinho: ruído filtrado que sobe de tom, com um tremor mecânico. */
function servo(c, t0, dur, vol, sobe) {
const fonte = c.createBufferSource();
fonte.buffer = bufferRuido(c); fonte.loop = true;
const f = c.createBiquadFilter();
f.type = 'bandpass'; f.Q.value = 9;
f.frequency.setValueAtTime(sobe === false ? 1500 : 500, t0);
f.frequency.exponentialRampToValueAtTime(sobe === false ? 500 : 1500, t0 + dur);
const g = c.createGain(), trem = c.createOscillator(), tg = c.createGain();
trem.frequency.value = 38; tg.gain.value = vol * 0.35;
trem.connect(tg); tg.connect(g.gain);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol * 0.6, t0 + 0.05);
g.gain.setValueAtTime(vol * 0.6, t0 + dur - 0.06);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
fonte.connect(f); f.connect(g); g.connect(saida(c));
fonte.start(t0); trem.start(t0);
fonte.stop(t0 + dur + 0.05); trem.stop(t0 + dur + 0.05);
}
function clique(c, t0, vol) {
const fonte = c.createBufferSource();
fonte.buffer = bufferRuido(c);
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 3000;
const g = c.createGain();
g.gain.setValueAtTime(vol * 0.5, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.03);
fonte.connect(f); f.connect(g); g.connect(saida(c));
fonte.start(t0); fonte.stop(t0 + 0.05);
}
const O = [500, 900], I = [1800, 2400], U = [350, 700], A = [900, 1300]; // vogais (formantes)
// Cada som: (c, t, v) → agenda as sílabas. Volumes relativos ao volume do app.
const SONS = {
feliz: (c, t, v) => { silaba(c, t, 0.12, 620, 900, v, { vogal: [1200, 2000] }); silaba(c, t + 0.13, 0.18, 900, 1250, v, { vogal: I, vib: [9, 18] }); },
alegre: (c, t, v) => [560, 700, 860, 1100].forEach((f, i) => silaba(c, t + i * 0.085, 0.09, f, f * 1.25, v * 0.85, { vogal: I, forma: 'square' })),
coracao: (c, t, v) => { silaba(c, t, 0.55, 760, 560, v, { vogal: [800, 1300], vib: [7, 28] }); },
piscadinha: (c, t, v) => { clique(c, t, v); silaba(c, t + 0.04, 0.08, 1000, 1300, v * 0.6, { forma: 'square', vogal: I }); },
surpreso: (c, t, v) => { silaba(c, t, 0.22, 260, 1100, v, { vogal: [450, 2000] }); silaba(c, t + 0.22, 0.12, 1100, 820, v * 0.8, { vogal: I }); },
bravo: (c, t, v) => [0, 0.13, 0.26].forEach((d, i) => silaba(c, t + d, 0.1, 480 - i * 30, 380 - i * 30, v * 0.85, { forma: 'square', vogal: [1300, 900] })), // bipes zangados
triste: (c, t, v) => { silaba(c, t, 0.65, 640, 380, v * 0.85, { vogal: [1200, 750], vib: [5, 14] }); },
dormir: (c, t, v) => { silaba(c, t, 0.35, 460, 760, v * 0.7, { vogal: [900, 1500] }); silaba(c, t + 0.36, 0.75, 760, 340, v * 0.6, { vogal: [1500, 700], vib: [4, 8] }); },
acordar: (c, t, v) => { servo(c, t, 0.25, v * 0.5); [300, 450, 700].forEach((f, i) => silaba(c, t + 0.22 + i * 0.1, 0.1, f, f * 1.2, v * 0.8, { vogal: I, forma: 'square' })); silaba(c, t + 0.56, 0.2, 700, 1050, v, { vogal: [1200, 2200], vib: [8, 15] }); },
acenar: (c, t, v) => { servo(c, t, 0.3, v * 0.45); silaba(c, t + 0.28, 0.13, 520, 820, v, { vogal: [1000, 1900] }); silaba(c, t + 0.44, 0.2, 760, 1080, v, { vogal: I, vib: [9, 20] }); },
esconder: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 0.1, 0.08, 950, 1350, v * 0.8, { vogal: I }); for (let i = 0; i < 5; i++) silaba(c, t + 0.6 + i * 0.09, 0.05, 1200 + (i % 2) * 180, 1300, v * 0.35, { forma: 'square', vogal: I }); servo(c, t + 2.05, 0.4, v * 0.45, false); },
espiar: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 1.0, 0.3, 480, 760, v * 0.9, { vogal: [600, 1700] }); silaba(c, t + 2.0, 0.16, 800, 1150, v, { vogal: I }); },
pensar: (c, t, v) => { servo(c, t, 0.35, v * 0.4); silaba(c, t + 0.35, 0.5, 520, 640, v * 0.7, { vogal: [900, 1600], vib: [5, 10] }); [0.95, 1.12].forEach((d) => silaba(c, t + d, 0.05, 1500, 1600, v * 0.35, { forma: 'square', vogal: I })); }, // "hmm?" e bipinhos
musica: (c, t, v) => [523, 659, 784, 659, 880, 784, 659, 1047].forEach((f, i) => silaba(c, t + i * 0.26, 0.2, f, f * 1.02, v * 0.6, { forma: 'triangle', vogal: A, vib: [6, 5] })),
};
// Gerador previsível: a mesma frase sempre "soa" igual.
function semente(texto) {
let h = 2166136261;
for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
return () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; };
}
/** Balbucio de robô para uma resposta: ~1 sílaba a cada 6 letras, no máximo 16. */
function balbuciar(c, texto, v) {
texto = String(texto || '').trim();
if (!texto) return 0;
const rnd = semente(texto), n = Math.max(2, Math.min(16, Math.ceil(texto.length / 6)));
const pergunta = /\?\s*$/.test(texto), exclama = /!\s*$/.test(texto);
const vogais = [O, I, A, U];
let t = c.currentTime + 0.03, tom = 520 + rnd() * 180;
for (let i = 0; i < n; i++) {
const ultima = i === n - 1;
const dur = 0.07 + rnd() * 0.1;
let para = tom * (0.8 + rnd() * 0.5);
if (ultima) para = pergunta ? tom * 1.6 : exclama ? tom * 1.35 : tom * 0.7;
silaba(c, t, ultima ? dur + 0.08 : dur, tom, para, v * (0.65 + rnd() * 0.3), { vogal: vogais[Math.floor(rnd() * 4)], vib: rnd() < 0.3 ? [8, 14] : null, forma: rnd() < 0.25 ? 'square' : 'sawtooth' });
t += dur + 0.025 + rnd() * 0.05;
tom = Math.max(330, Math.min(1000, para * (0.9 + rnd() * 0.25)));
}
return t - c.currentTime;
}
function tocar(c, nome, v) {
const f = SONS[nome];
if (!f || !c) return false;
f(c, c.currentTime + 0.02, v);
return true;
}
window.VozRobo = { tocar, balbuciar, servo: (c, dur, v) => servo(c, c.currentTime + 0.01, dur, v), sons: Object.keys(SONS) };
})();
@@ -28,6 +28,15 @@ public final class ControleRemoto {
private volatile ScheduledExecutorService worker; private volatile ScheduledExecutorService worker;
private long consultarEm; private long consultarEm;
private long quadroEm; private long quadroEm;
// Foto do evento (histórico): o servidor pede, o app tira e envia.
private volatile long fotoPedida = 0, fotoFeita = 0, fotoId = 0, pedidoEm = 0;
private volatile byte[] fotoJpeg = null;
/** Id do evento que está esperando foto (0 = nenhum). */
public long fotoPendente() { return fotoJpeg == null ? fotoPedida : 0; }
/** Foto vinda do vídeo ao vivo (quando a câmera de movimento está desligada). */
public void fotoRTC(byte[] jpeg) {
if (fotoPedida > 0 && fotoJpeg == null && jpeg != null && jpeg.length > 0) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; }
}
private long logEm; private long logEm;
private final Runnable mudanca; private final Runnable mudanca;
public interface RTCListener { boolean receber(JSONObject pacote); } public interface RTCListener { boolean receber(JSONObject pacote); }
@@ -118,6 +127,7 @@ public final class ControleRemoto {
mudanca.run(); mudanca.run();
} }
if (videoRTC()) rtcTick(base); if (videoRTC()) rtcTick(base);
fotoTick(base);
byte[] jpeg = pendente; byte[] jpeg = pendente;
pendente = null; pendente = null;
if (jpeg != null && preview()) { if (jpeg != null && preview()) {
@@ -147,10 +157,13 @@ public final class ControleRemoto {
return c; return c;
} }
public void quadro(Image image, int rotacao) { public void quadro(Image image, int rotacao) {
if (videoRTC()) return; boolean querFoto = fotoPedida > 0 && fotoJpeg == null;
long agora = System.currentTimeMillis(); long agora = System.currentTimeMillis();
if (!preview() || pendente != null || agora < quadroEm) return; if (!querFoto) {
quadroEm = agora + 200; if (videoRTC()) return;
if (!preview() || pendente != null || agora < quadroEm) return;
quadroEm = agora + 200;
}
try { try {
Image.Plane[] p = image.getPlanes(); Image.Plane[] p = image.getPlanes();
ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()}; ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()};
@@ -171,10 +184,44 @@ public final class ControleRemoto {
if (rotated != b) rotated.recycle(); if (rotated != b) rotated.recycle();
b.recycle(); b.recycle();
} }
if (preview()) pendente = jpeg; if (querFoto) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; }
else if (preview()) pendente = jpeg;
} catch (Exception e) { Log.w("CarinhaPC", "quadro descartado: " + e); } } catch (Exception e) { Log.w("CarinhaPC", "quadro descartado: " + e); }
} }
/** Consulta o pedido de foto (1x/s) e envia a foto pronta para o histórico. */
private void fotoTick(String base) throws Exception {
byte[] jpeg = fotoJpeg;
if (jpeg != null) {
long id = fotoId;
fotoJpeg = null;
HttpURLConnection c = abrir(base + "/historico/foto?id=" + id);
try {
c.setRequestMethod("POST"); c.setDoOutput(true);
c.setRequestProperty("Content-Type", "image/jpeg");
c.setFixedLengthStreamingMode(jpeg.length);
try (java.io.OutputStream out = c.getOutputStream()) { out.write(jpeg); }
int code = c.getResponseCode();
Log.i("CarinhaPC", "foto do evento " + id + ": HTTP " + code);
fotoFeita = id;
} finally { c.disconnect(); }
}
long agora = System.currentTimeMillis();
if (agora < pedidoEm) return;
pedidoEm = agora + 1000;
HttpURLConnection c = abrir(base + "/historico/pedido");
try {
if (c.getResponseCode() != 200) return;
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (InputStream in = c.getInputStream()) {
byte[] buffer = new byte[512]; int n;
while ((n = in.read(buffer)) != -1 && bytes.size() < 4096) bytes.write(buffer, 0, n);
}
long id = new JSONObject(bytes.toString("UTF-8")).optLong("id", 0);
if (id > 0 && id != fotoFeita && id != fotoId && fotoJpeg == null) fotoPedida = id;
} finally { c.disconnect(); }
}
private void rtcTick(String base) throws Exception { private void rtcTick(String base) throws Exception {
String json; String json;
while ((json = sinais.poll()) != null) { while ((json = sinais.poll()) != null) {
@@ -198,6 +198,8 @@ public class MainActivity extends Activity {
} }
atualizarSensores(); atualizarSensores();
atualizarBrilho(); atualizarBrilho();
if (remoto.fotoPendente() > 0 && rtcAtivo)
rtcWeb.evaluateJavascript("window.rtcFoto&&window.rtcFoto()", null);
} }
} }
}); });
@@ -1079,7 +1081,7 @@ public class MainActivity extends Activity {
web.evaluateJavascript("window.carinhaBateria={nivel:" + percent + ",carregando:" + charging + "}", null); web.evaluateJavascript("window.carinhaBateria={nivel:" + percent + ",carregando:" + charging + "}", null);
} }
} }
web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length)}):null", web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length),apagada:!!window.carinhaTelaApagada}):null",
new ValueCallback<String>() { new ValueCallback<String>() {
@Override public void onReceiveValue(String valor) { @Override public void onReceiveValue(String valor) {
if (!emPrimeiroPlano) return; if (!emPrimeiroPlano) return;
@@ -1091,6 +1093,7 @@ public class MainActivity extends Activity {
float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(), float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(),
System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(), System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(),
estado.optBoolean("ocupado"), estado.optBoolean("dormindo")); estado.optBoolean("ocupado"), estado.optBoolean("dormindo"));
if (estado.optBoolean("apagada")) nivel = 0f; // tela preta + brilho mínimo
WindowManager.LayoutParams attr = getWindow().getAttributes(); WindowManager.LayoutParams attr = getWindow().getAttributes();
if (Math.abs(attr.screenBrightness - nivel) > 0.005f) { if (Math.abs(attr.screenBrightness - nivel) > 0.005f) {
attr.screenBrightness = nivel; attr.screenBrightness = nivel;
@@ -1104,6 +1107,14 @@ public class MainActivity extends Activity {
} }
private final class PonteRTC { 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) {
try {
int virgula = dataUrl == null ? -1 : dataUrl.indexOf(',');
if (virgula < 0 || dataUrl.length() > 4_000_000) return;
remoto.fotoRTC(android.util.Base64.decode(dataUrl.substring(virgula + 1), android.util.Base64.DEFAULT));
} catch (Exception e) { Log.w("CarinhaPC", "foto do video falhou: " + e); }
}
@JavascriptInterface public void rastrear(final String json) { @JavascriptInterface public void rastrear(final String json) {
if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return; if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return;
try { try {
+1 -1
View File
@@ -30,7 +30,7 @@ def main():
def run(*command): def run(*command):
subprocess.run([str(x) for x in command], check=True) subprocess.run([str(x) for x in command], check=True)
for name in ('carinha.html', 'camera.html', 'rosto-motor.js'): for name in ('carinha.html', 'camera.html', 'rosto-motor.js', 'voz-robo.js'):
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name) shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder: with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
tmp = Path(folder) tmp = Path(folder)
+410 -46
View File
@@ -5,7 +5,13 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<style> <style>
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000} 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} .corpo{fill-rule:evenodd}
.traco{fill:none;stroke-linecap:round} .traco{fill:none;stroke-linecap:round}
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)} .corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
@@ -26,6 +32,7 @@
</svg> </svg>
<div id="fps"></div> <div id="fps"></div>
<script src="rosto-motor.js"></script> <script src="rosto-motor.js"></script>
<script src="voz-robo.js"></script>
<script> <script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao /* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */ próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
@@ -91,12 +98,33 @@ addEventListener("resize", ajustar);
const PRETO = [6, 8, 14]; const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255]; const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false }; const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
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;
if (apagar !== SONO.apagada) {
SONO.apagada = apagar;
window.carinhaTelaApagada = apagar;
document.body.style.visibility = apagar ? "hidden" : "";
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
}
}
setInterval(cuidarDaTela, 1000);
const CIANO_E = [150, 232, 255]; const CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78]; const VERM = [255, 78, 78];
const ROSA = [255, 112, 176]; const ROSA = [255, 112, 176];
const AMARELO = [255, 218, 96]; const AMARELO = [255, 218, 96];
const AZUL = [130, 170, 255]; const AZUL = [130, 170, 255];
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
const VERDE = [120, 255, 160]; const VERDE = [120, 255, 160];
const BRANCO = [245, 250, 255]; const BRANCO = [245, 250, 255];
@@ -149,8 +177,18 @@ function caminhoLinha(x0, y0, x1, y1) {
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1; return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
} }
/* formas da boca (o humor escolhe qual) */ /* formas da boca (o humor escolhe qual) */
function deslocamentoBoca() {
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
}
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
function metadeOlho(esc) {
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
return m * RY * esc;
}
function caminhoBoca(tipo) { function caminhoBoca(tipo) {
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2; const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
if (tipo === "aberta") { // surpresa: um "O" if (tipo === "aberta") { // surpresa: um "O"
const rx = l * 0.17, ry = l * 0.22; const rx = l * 0.17, ry = l * 0.22;
return caminhoElipse(cx, cy, rx, ry); return caminhoElipse(cx, cy, rx, ry);
@@ -171,8 +209,25 @@ function caminhoBoca(tipo) {
return "M " + (cx - meia) + "," + (cy - l * 0.10) + return "M " + (cx - meia) + "," + (cy - l * 0.10) +
" Q " + cx + "," + (cy + l * 0.42) + " " + (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 // repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0"; 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 // ------------------------------------------------------------------ desenho
@@ -224,6 +279,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.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(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) { if (icone) {
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||''; forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy); p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
@@ -291,7 +353,7 @@ function sequencias() {
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]]; const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18], const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]]; [{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09], const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
[{ dy: -10 }, 0.5], [{ dy: -10 }, 0.5],
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]]; [{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1], const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
@@ -300,7 +362,7 @@ function sequencias() {
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15], const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15], [{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
[{ forma: "aberto", cor: CIANO }, 0.3]]; [{ forma: "aberto", cor: CIANO }, 0.3]];
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5], const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]]; [{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5], const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]]; [{ forma2: "aberto", esc: 1.0 }, 0.18]];
@@ -313,7 +375,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]]; [{ 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], const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]]; [{ 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, piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre }; bravo, coracao, triste, piscadinha, alegre };
} }
@@ -327,12 +405,92 @@ const r = {
const SEQS = sequencias(); const SEQS = sequencias();
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo", const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"]; "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() { function proximo() {
if (!r.fila.length) { r.kf = null; return; } if (!r.fila.length) { r.kf = null; return; }
const [alvo, dur] = r.fila[0]; const [alvo, dur] = r.fila[0];
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy }; r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
r.cor_ini = r.cor.slice(); r.cor_ini = r.cor.slice();
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
if ("forma" in alvo) r.forma = alvo.forma; if ("forma" in alvo) r.forma = alvo.forma;
if ("forma2" in alvo) r.forma2 = alvo.forma2; if ("forma2" in alvo) r.forma2 = alvo.forma2;
if ("boca" in alvo) r.boca = alvo.boca; if ("boca" in alvo) r.boca = alvo.boca;
@@ -340,7 +498,22 @@ function proximo() {
r.kf_t = 0; 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) { 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]) { if (SEQS[nome]) {
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]); r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
proximo(); proximo();
@@ -364,10 +537,40 @@ function passo(dt) { // avanca a fila; true se ha gesto em
return true; 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" function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) { if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono"; r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2)); r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
r.ab = 0; r.ab2 = 0; r.ab = 0; r.ab2 = 0;
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4); r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0; r.dx = 0;
@@ -378,9 +581,28 @@ function repouso(t) { // pose calma: olha devagar, "respira
r.boca = "neutra"; r.boca = "neutra";
r.cor = CIANO.slice(); r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0; 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) // olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX; // centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY; 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() { function desenhar() {
@@ -392,7 +614,7 @@ function desenhar() {
// desliza na direcao do movimento; parado, volta ao repouso discreto // desliza na direcao do movimento; parado, volta ao repouso discreto
const tAgora = performance.now() / 1000; const tAgora = performance.now() / 1000;
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo; 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 mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy; const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
@@ -400,9 +622,11 @@ function desenhar() {
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; }); OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) { if (comMotor) {
window.MotorRosto.desenhar({ 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, icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null, carga: window.carinhaBateria || null,
iris: EXTRAS.iris,
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null, desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
parado: !r.kf && !seguindo && !r.dormindo, parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({ olhos: olhos.map(([c, abx, fx, lado]) => ({
@@ -416,24 +640,35 @@ function desenhar() {
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc, olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6); abx, fx, r.cor, lado, dx, dy * 0.6);
} }
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none"; const matriz = comMotor && ROSTO.motor === "matriz";
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
else desenharBoca(); else desenharBoca();
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1)); const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none'; brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue; if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2; brow.setAttribute('d', caminhoSobrancelha(i, esc));
const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85;
const y = c[1] - RY * esc - 20 * 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('stroke', rgb(r.cor)); brow.setAttribute('stroke', rgb(r.cor));
brow.setAttribute('stroke-width', 7 * ESC); brow.setAttribute('stroke-width', 7 * ESC);
} }
} }
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
function tracosDoRosto(esc) {
const tracos = [];
if (EXTRAS.boca) {
const forma = r.boca;
if (r.dormindo) { // dormindo na Matriz: "– _ –"
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
}
if (EXTRAS.sobrancelhas && !r.dormindo) {
for (let i = 0; i < 2; i++) tracos.push({ d: caminhoSobrancelha(i, esc), largura: 9 * ESC });
}
return tracos;
}
// ------------------------------------------------------------------ controle // ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */ /* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) { function trocarRosto(nome) {
@@ -470,8 +705,25 @@ function trata(ordem) {
if (v.length === 3 && v.every((n) => !isNaN(n))) { if (v.length === 3 && v.every((n) => !isNaN(n))) {
CIANO.splice(0, 3, ...v); r.cor = v; 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") { } else if (ordem === "dormir" || ordem === "sono") {
r.dormindo = true; r.dormindo = true;
atividade(null);
} else if (ordem === "acordar") { } else if (ordem === "acordar") {
r.dormindo = false; animar("surpreso"); r.dormindo = false; animar("surpreso");
} else if (ordem === "aleatorio") { } else if (ordem === "aleatorio") {
@@ -479,12 +731,12 @@ function trata(ordem) {
} else if (SEQS[ordem]) { } else if (SEQS[ordem]) {
if (window.MotorRosto) { if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 }); if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
} }
animar(ordem); animar(ordem);
} else if (ordem === "calma") { } else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro. // E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false; r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
atividade(null);
} else if (ordem === "parar") { } else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; } try { window.close(); } catch (e) { r.fila = []; }
} }
@@ -501,7 +753,7 @@ function pose(nome) {
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) { for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k]; if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
} }
if ("cor" in alvo) r.cor = alvo.cor.slice(); if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
congelado = true; congelado = true;
desenhar(); desenhar();
return true; return true;
@@ -556,6 +808,7 @@ if (MODO_PAINEL) {
const m = e.data; const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem); 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 === '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 (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); } else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
@@ -569,9 +822,11 @@ if (MODO_PAINEL) {
if (!inicio) return; if (!inicio) return;
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t; const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
inicio = null; inicio = null;
if (r.dormindo) { trata('acordar'); return; } if (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; }
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); 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) { if (dur < 350 && Math.hypot(dx, dy) < 15) {
cuidar('cutucar');
animar('surpreso'); animar('surpreso');
setTimeout(() => animar('feliz'), 700); setTimeout(() => animar('feliz'), 700);
} }
@@ -617,6 +872,49 @@ const MIC = {
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos 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) { function som(nivel) {
const n = Math.max(0, Math.min(1, Number(nivel) || 0)); const n = Math.max(0, Math.min(1, Number(nivel) || 0));
const agora = performance.now() / 1000; const agora = performance.now() / 1000;
@@ -631,8 +929,8 @@ function som(nivel) {
if (MIC.piso === null) MIC.piso = n; if (MIC.piso === null) MIC.piso = n;
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015); else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
const temSom = n >= MIC.limiarSom; // ruido audivel (absoluto) const temSom = n >= limiar(MIC.limiarSom); // ruido audivel (absoluto)
const alto = n >= MIC.limiarAlto && (n - MIC.piso) >= MIC.ganho; const alto = n >= limiar(MIC.limiarAlto) && (n - MIC.piso) >= limiar(MIC.ganho);
if (temSom) MIC.ultimoSom = agora; if (temSom) MIC.ultimoSom = agora;
// registro periodico (aparece no logcat do app: prova que o ouvido chegou) // registro periodico (aparece no logcat do app: prova que o ouvido chegou)
@@ -641,15 +939,19 @@ function som(nivel) {
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2)); 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; r.dormindo = false;
animar("surpreso"); animar("surpreso");
MIC.proxSurpresa = agora + MIC.cooldown; MIC.proxSurpresa = agora + MIC.cooldown;
console.log("ouvido: acordou nivel=" + n.toFixed(2)); console.log("ouvido: acordou nivel=" + n.toFixed(2));
relatarSom("acordou", n); relatarSom("acordou", n);
} else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) { }
MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino } else if (contar(CONTA.somReagir, PERFIL.qualquer ? temSom : alto, agora, PERFIL.reagir, "ouvido: estimulo") &&
animar("surpreso"); 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)); console.log("ouvido: surpreso nivel=" + n.toFixed(2));
relatarSom("surpreso", n); relatarSom("surpreso", n);
} }
@@ -662,6 +964,7 @@ function checarSilencio(agora) {
if (!MIC.ativo) return; // telinha do Pi: sem ouvido if (!MIC.ativo) return; // telinha do Pi: sem ouvido
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
if (r.dormindo) return; 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 // movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0); const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
if (agora - atividade >= MIC.segSilencio) { if (agora - atividade >= MIC.segSilencio) {
@@ -675,6 +978,25 @@ function checarSilencio(agora) {
journal do carinha-web (so quando o microfone esta ativo). */ journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) { function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
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;
try {
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
} catch (e) {}
} }
// ------------------------------------------------------ presenca (movimento) // ------------------------------------------------------ presenca (movimento)
@@ -714,7 +1036,7 @@ function rastrear(t) {
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65; MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
if(t.atividade===true) { if(t.atividade===true) {
MOV.ultimoMov=agora; 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) { function movimento(vel, cx, cy) {
@@ -728,7 +1050,7 @@ function movimento(vel, cx, cy) {
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; } if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
if (TRACK.alvo && agora-TRACK.visto<.35) 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; if (mexeu) MOV.ultimoMov = agora;
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro // olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
@@ -743,17 +1065,21 @@ function movimento(vel, cx, cy) {
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2)); MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
} }
const forte = v >= MOV.limiarForte; const forte = v >= limiar(MOV.limiarForte);
if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente 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; r.dormindo = false;
animar("surpreso"); animar("surpreso");
MOV.proxSurpresa = agora + MOV.cooldown; MOV.proxSurpresa = agora + MOV.cooldown;
console.log("presenca: acordou nivel=" + v.toFixed(2)); console.log("presenca: acordou nivel=" + v.toFixed(2));
relatarMov("acordou", v); 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 // susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
MOV.proxSurpresa = agora + MOV.cooldown; MOV.proxSurpresa = agora + MOV.cooldown * PERFIL.espera;
animar("surpreso"); animar(reacaoEstimulo());
console.log("presenca: surpreso nivel=" + v.toFixed(2)); console.log("presenca: surpreso nivel=" + v.toFixed(2));
relatarMov("surpreso", v); relatarMov("surpreso", v);
} }
@@ -763,6 +1089,7 @@ function movimento(vel, cx, cy) {
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */ /* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) { function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
registrarEvento("movimento", evento, n);
} }
// ------------------------------------------------- camera oculta (sensor) // ------------------------------------------------- camera oculta (sensor)
@@ -893,7 +1220,15 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null; let configRecebida = null;
function configurarApp(c) { function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; 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; AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume; AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir; COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
@@ -923,6 +1258,8 @@ async function sincronizarApp() {
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2, movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida, centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho, brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
atividade: window.MotorRosto && window.MotorRosto.atividadeAtual ? window.MotorRosto.atividadeAtual() : null,
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho, aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
@@ -969,15 +1306,32 @@ const SONS = {
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); }, dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
}; };
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
os sons dos gestos ficam quietos para não embolar. */
function falar(texto) {
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
const c = contextoAudio();
if (!c) return;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try {
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
AUDIO.mudoAte = performance.now() + dur * 1000;
} catch (e) { /* segue sem voz */ }
}
function tocarSom(gesto) { function tocarSom(gesto) {
if (!AUDIO.ligado) return false; if (!AUDIO.ligado) return false;
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
const f = SONS[gesto]; const f = SONS[gesto];
if (!f) return false; if (!voz && !f) return false;
const c = contextoAudio(); const c = contextoAudio();
if (!c) return false; if (!c) return false;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } } if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try { try {
f(c, c.currentTime + 0.02, AUDIO.volume); if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo // aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
console.log("voz: " + gesto + " ctx=" + c.state); console.log("voz: " + gesto + " ctx=" + c.state);
return true; return true;
@@ -1003,6 +1357,11 @@ function laco(t) {
if (window.innerWidth !== W || window.innerHeight !== H) ajustar(); if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
checarSilencio(t / 1000); 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);
const ocupado = congelado ? true : passo(dt); const ocupado = congelado ? true : passo(dt);
if (!ocupado) { if (!ocupado) {
@@ -1013,15 +1372,18 @@ function laco(t) {
} else if (t >= proxPiscar) { } else if (t >= proxPiscar) {
animar("piscar"); animar("piscar");
proxPiscar = t + 1600 + Math.random() * 3500; proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto) { } else if (AUTO && t >= proxGesto && !ATIV) {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); animar(gestoSolto());
proxGesto = t + 7000 + Math.random() * 8000; // 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) { } else if (t >= proxGesto) {
proxGesto = t + 7000 + Math.random() * 8000; proxGesto = t + 7000 + Math.random() * 8000;
} }
desenhar(); desenhar();
if (window.MotorRosto && window.MotorRosto.palco) window.MotorRosto.palco({ t: t / 1000, cor: CIANO });
contador++; contador++;
if (t - marco >= 1000) { if (t - marco >= 1000) {
@@ -1064,6 +1426,7 @@ setTimeout(relatar, 1500);
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return; try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;} const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id; if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
falar(m.texto);
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto); MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto}); if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
}catch(e){}finally{fetching=false;} }catch(e){}finally{fetching=false;}
@@ -1113,6 +1476,7 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
const m = s.ultima; const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return; if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id; ultimaConversa = m.id;
falar(m.texto);
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto); MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto }); if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
}); });
+24 -5
View File
@@ -7,8 +7,19 @@ import time
from urllib.request import Request,urlopen from urllib.request import Request,urlopen
from comum import gravar_atomico, ler_json 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') 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): def validar_intencao(data):
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS: 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') 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.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.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
self.publicar=lambda tipo,dados:None 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 try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
except (OSError,ValueError,KeyError,TypeError):pass except (OSError,ValueError,KeyError,TypeError):pass
threading.Thread(target=self.loop,daemon=True).start() 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') if self.busy or self.pending or self.conversando:raise ValueError('IA processando; aguarde')
self.pending=event self.pending=event
return dict(ok=True) 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): 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()) request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cabecalhos())
start=time.monotonic() start=time.monotonic()
with urlopen(request,timeout=90) as response:data=json.load(response) with urlopen(request,timeout=90) as response:data=json.load(response)
content=data['choices'][0]['message']['content'] content=data['choices'][0]['message']['content']
gesto=validar_intencao(json.loads(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()) return dict(evento=event,gesto=gesto,segundos=round(time.monotonic()-start,2),horario=time.time())
def loop(self): def loop(self):
while True: while True:
@@ -65,10 +82,12 @@ class Cerebro:
with self.controle.lock: with self.controle.lock:
online=time.monotonic()-self.controle.status_at<5 online=time.monotonic()-self.controle.status_at<5
state=dict(self.controle.estado) 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' 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 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 if not event:continue
with self.lock: with self.lock:
if self.conversando:continue if self.conversando:continue
@@ -79,7 +98,7 @@ class Cerebro:
if self.ativo and generation==self.generation: if self.ativo and generation==self.generation:
self.result=result;self.error='';self.history.append(result['gesto']);self.history=self.history[-6:] self.result=result;self.error='';self.history.append(result['gesto']);self.history=self.history[-6:]
with self.controle.lock: 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']) self.enviar(result['gesto'])
except Exception as e: except Exception as e:
with self.lock:self.error=str(e)[:200] with self.lock:self.error=str(e)[:200]
+17 -2
View File
@@ -20,7 +20,9 @@ PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
somaltolim=0.55, sompausa=20, somcooldown=5, somaltolim=0.55, sompausa=20, somcooldown=5,
movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40, movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30, economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30,
boca=True, sobrancelhas=False, videoRTC=True, 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, sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
cameraLado='user', videoResolucao='auto', videoFPS=0, cameraLado='user', videoResolucao='auto', videoFPS=0,
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True) rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
@@ -30,7 +32,9 @@ ESCOLHAS = dict(cameraLado=('user', 'environment'),
LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1), LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120), somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100), movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600)) brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600),
subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120),
somAcordar=(1, 20), noiteInicio=(0, 23), noiteFim=(0, 23))
def validar(dados): def validar(dados):
@@ -52,6 +56,17 @@ def validar(dados):
return 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): def ip_informado(dados):
"""IP que o próprio app informa (aparelho.ip); o endereço da conexão pode ser de um proxy.""" """IP que o próprio app informa (aparelho.ip); o endereço da conexão pode ser de um proxy."""
aparelho = dados.get('aparelho') 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
+4 -3
View File
@@ -27,8 +27,8 @@ Set-Location $PSScriptRoot -ErrorAction Stop
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai). # chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
$arquivos = @( $arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py', 'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py',
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'version.json', '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' 'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
) )
if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' } if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' }
@@ -92,7 +92,8 @@ systemctl restart carinha-pc
echo "backup em `$backup" echo "backup em `$backup"
"@ "@
Write-Host '== Instalando na placa' 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)' } if ($LASTEXITCODE) { throw 'instalação na placa falhou (o backup foi feito antes de qualquer remoção)' }
Write-Host '== Conferindo' Write-Host '== Conferindo'
+368
View File
@@ -0,0 +1,368 @@
"""Histórico de detecções (PIR da placa, movimento e som do celular) com uma foto por evento.
- PIR: lê o pino com `gpiomon` (libgpiod). Padrão: gpiochip1 linha 70 = pino 11 (PC6) do
Orange Pi Zero 3; outro pino com CARINHA_PIR=gpiochip1:79.
- Foto: ao registrar um evento, o servidor deixa um "pedido de foto"; o app do S10 consulta
/historico/pedido, tira a foto com a câmera frontal e envia em /historico/foto?id=N.
- Guarda só os últimos 3 dias (eventos e fotos mais antigos são apagados sozinhos), com teto de
segurança de 2000 eventos e 500 fotos. Cada sensor e a foto podem ser desligados.
"""
import json
import os
from pathlib import Path
import shutil
import sqlite3
import subprocess
import threading
import time
from contextlib import contextmanager
from urllib.parse import parse_qs, urlsplit
from comum import gravar_atomico, ler_json
TIPOS = ('pir', 'movimento', 'som')
PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15, dias=3,
presenca=True, ausencia=5) # modo presença: o PIR liga a câmera; sem ninguém, ela desliga
MAX_EVENTOS, MAX_FOTOS = 2000, 500 # teto de segurança; quem manda é a retenção em dias
VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto
class Historico:
def __init__(self, pasta, iniciar_pir=True):
self.pasta = Path(pasta)
self.fotos = self.pasta / 'fotos'
self.fotos.mkdir(exist_ok=True)
self.caminho_db = str(self.pasta / 'historico.db')
self.arquivo_config = self.pasta / 'historico.json'
self.lock = threading.RLock()
self.publicar = lambda tipo, dados: None
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
# Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia).
self.ao_presenca = lambda: None
self.ao_ausencia = lambda: None
self.presenca = dict(estado='presente', desde=time.time(), ultimo=time.time())
self.config = dict(PADRAO)
try:
salvo = json.loads(self.arquivo_config.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo))
except (OSError, ValueError, TypeError):
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,
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)')
self.limpar_antigos()
if iniciar_pir:
threading.Thread(target=self.vigiar_pir, daemon=True).start()
threading.Thread(target=self.faxina, daemon=True).start()
threading.Thread(target=self.vigiar_ausencia, daemon=True).start()
@contextmanager
def db(self):
con = sqlite3.connect(self.caminho_db)
try:
with con:
yield con
finally:
con.close()
@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 == 'ausencia':
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 120:
raise ValueError('Ausência de 1 a 120 minutos')
elif chave == 'dias':
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30:
raise ValueError('Guardar de 1 a 30 dias')
elif chave == 'intervalo':
if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600:
raise ValueError('Intervalo entre 0 e 600 s')
elif not isinstance(valor, bool):
raise ValueError(chave + ' precisa ser verdadeiro ou falso')
limpo[chave] = valor
return limpo
# ------------------------------------------------------------------ retenção
def limpar_antigos(self, agora=None):
"""Apaga eventos (e fotos) mais velhos que a retenção em dias."""
limite = (agora if agora is not None else time.time()) - self.config['dias'] * 86400
with self.lock, self.db() as db:
velhos = db.execute('SELECT id, foto FROM eventos WHERE horario < ?', (limite,)).fetchall()
for vid, foto in velhos:
self.apagar_arquivo(foto)
db.execute('DELETE FROM eventos WHERE horario < ?', (limite,))
if velhos:
self.publicar('historico', dict(expirados=len(velhos)))
return len(velhos)
# ------------------------------------------------------------------ modo presença
def marcar_presenca(self, agora=None):
agora = agora if agora is not None else time.time()
with self.lock:
self.presenca['ultimo'] = agora
voltou = self.presenca['estado'] != 'presente'
if voltou:
self.presenca.update(estado='presente', desde=agora)
if voltou and self.config['presenca']:
self.ao_presenca()
def checar_ausencia(self, agora=None):
"""Sem presença no PIR por `ausencia` minutos: desliga câmera, dorme e fica quieto."""
agora = agora if agora is not None else time.time()
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.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)
try:
self.checar_ausencia()
except Exception:
pass
def faxina(self):
while True:
time.sleep(600)
try:
self.limpar_antigos()
except Exception:
pass
# ------------------------------------------------------------------ eventos
def registrar(self, tipo, evento='', nivel=None, agora=None):
"""Grava o evento se o sensor estiver ligado e fora do intervalo mínimo; devolve o id ou None."""
if tipo not in TIPOS:
raise ValueError('Tipo de evento inválido')
agora = agora if agora is not None else time.time()
with self.lock:
if not self.config.get(tipo, False):
return None
if tipo in self.ultimo and agora - self.ultimo[tipo] < self.config['intervalo']:
return None
self.ultimo[tipo] = agora
evento = str(evento or '')[:40]
nivel = float(nivel) if isinstance(nivel, (int, float)) and not isinstance(nivel, bool) else None
with self.db() as db:
ident = db.execute('INSERT INTO eventos(horario, tipo, evento, nivel) VALUES (?,?,?,?)',
(agora, tipo, evento, nivel)).lastrowid
velhos = db.execute('SELECT id, foto FROM eventos ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_EVENTOS,)).fetchall()
for vid, foto in velhos:
self.apagar_arquivo(foto)
db.execute('DELETE FROM eventos WHERE id=?', (vid,))
if self.config['foto']:
self.pedido = (ident, time.monotonic())
self.limpar_antigos(agora)
self.publicar('historico', dict(novo=self.evento(ident)))
try:
self.ao_detectar(tipo)
except Exception:
pass
return ident
def evento(self, ident):
with self.db() as db:
linha = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos WHERE id=?', (ident,)).fetchone()
return self.como_dict(linha) if linha else None
@staticmethod
def como_dict(linha):
i, h, t, e, n, f = linha
return dict(id=i, horario=h, tipo=t, evento=e, nivel=n, foto=bool(f))
def listar(self, limite=500):
with self.db() as db:
linhas = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos ORDER BY id DESC LIMIT ?', (limite,)).fetchall()
total = db.execute('SELECT count(*) FROM eventos').fetchone()[0]
return [self.como_dict(l) for l in linhas], total
def apagar(self, ident):
with self.lock, self.db() as db:
linha = db.execute('SELECT foto FROM eventos WHERE id=?', (ident,)).fetchone()
if linha:
self.apagar_arquivo(linha[0])
db.execute('DELETE FROM eventos WHERE id=?', (ident,))
self.publicar('historico', dict(apagado=ident))
def limpar(self):
with self.lock, self.db() as db:
db.execute('DELETE FROM eventos')
for f in self.fotos.glob('evento-*.jpg'):
f.unlink(missing_ok=True)
self.pedido = None
self.publicar('historico', dict(limpo=True))
def apagar_arquivo(self, nome):
if nome:
(self.fotos / nome).unlink(missing_ok=True)
# ------------------------------------------------------------------ foto
def pedido_atual(self):
with self.lock:
if self.pedido and time.monotonic() - self.pedido[1] < VALIDADE_PEDIDO:
return self.pedido[0]
self.pedido = None
return None
def guardar_foto(self, ident, jpeg):
if not (jpeg.startswith(b'\xff\xd8') and jpeg.endswith(b'\xff\xd9')) or len(jpeg) > 3_000_000:
raise ValueError('Esperava uma foto JPEG de até 3 MB')
with self.lock:
if not self.config['foto']:
raise ValueError('Fotos desligadas')
if self.pedido and self.pedido[0] == ident:
self.pedido = None
nome = f'evento-{ident}.jpg'
with self.db() as db:
if not db.execute('SELECT 1 FROM eventos WHERE id=?', (ident,)).fetchone():
raise ValueError('Evento não existe mais')
tmp = self.fotos / (nome + '.tmp')
tmp.write_bytes(jpeg); tmp.replace(self.fotos / nome)
db.execute('UPDATE eventos SET foto=? WHERE id=?', (nome, ident))
antigas = db.execute('SELECT id, foto FROM eventos WHERE foto IS NOT NULL ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_FOTOS,)).fetchall()
for vid, foto in antigas:
self.apagar_arquivo(foto)
db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,))
self.publicar('historico', dict(foto=ident))
# ------------------------------------------------------------------ PIR
def vigiar_pir(self):
"""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')
if os.name == 'posix' and not shutil.which('gpiomon'):
self.pir['erro'] = 'gpiomon não instalado (apt install gpiod)'
time.sleep(2)
continue
chip, _, linha = self.pir['linha'].partition(':')
try:
# pull-down: com o sensor desligado o pino não fica "flutuando" (disparos falsos)
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 linha_evento in self.processo_pir.stdout:
if not self.config['pir']:
break
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, ativo=False, erro=erro if self.config['pir'] else 'desligado')
time.sleep(3)
def parar_pir(self):
p, self.processo_pir = self.processo_pir, None
if p and p.poll() is None:
p.terminate()
try:
p.wait(timeout=2)
except subprocess.TimeoutExpired:
p.kill()
def status(self):
eventos, total = self.listar()
with self.lock:
return dict(config=dict(self.config), pir=dict(self.pir), presenca=dict(self.presenca), eventos=eventos, total=total,
max_eventos=MAX_EVENTOS, max_fotos=MAX_FOTOS)
# ------------------------------------------------------------------ HTTP
def rotas(self, h, metodo):
url = urlsplit(h.path)
rota = url.path
if not rota.startswith('/historico/'):
return False
try:
if metodo == 'GET' and rota == '/historico/status':
return h.json(self.status()) or True
if metodo == 'GET' and rota == '/historico/pedido':
return h.json(dict(id=self.pedido_atual())) or True
if metodo == 'GET' and rota.startswith('/historico/foto/'):
nome = rota.rsplit('/', 1)[-1]
if not nome.startswith('evento-') or not nome.endswith('.jpg') or '/' in nome or '..' in nome:
raise ValueError('Foto inválida')
arquivo = self.fotos / nome
if not arquivo.is_file():
h.json(dict(erro='Foto não encontrada'), 404)
return True
h.enviar(arquivo.read_bytes(), 'image/jpeg')
return True
if metodo == 'POST' and rota == '/historico/foto':
ident = int(parse_qs(url.query).get('id', ['0'])[0])
if h.headers.get('Content-Type', '').split(';')[0] != 'image/jpeg':
raise ValueError('Use image/jpeg')
tamanho = int(h.headers.get('Content-Length', '0'))
if not 0 < tamanho <= 3_000_000:
raise ValueError('Foto vazia ou grande demais')
self.guardar_foto(ident, h.rfile.read(tamanho))
return h.json(dict(ok=True)) or True
if metodo == 'POST':
dados = ler_json(h, 4096)
if rota == '/historico/evento':
ident = self.registrar(dados.get('tipo'), dados.get('evento'), dados.get('nivel'))
return h.json(dict(ok=True, id=ident)) or True
if rota == '/historico/config':
limpo = self.validar(dados)
with self.lock:
self.config.update(limpo)
gravar_atomico(self.arquivo_config, self.config)
if 'pir' in limpo and not limpo['pir']:
self.parar_pir()
if 'dias' in limpo:
self.limpar_antigos()
self.publicar('historico', dict(config=dict(self.config)))
return h.json(self.status()) or True
if rota == '/historico/apagar':
if type(dados.get('id')) is not int:
raise ValueError('ID inválido')
self.apagar(dados['id'])
return h.json(dict(ok=True)) or True
if rota == '/historico/limpar':
self.limpar()
return h.json(dict(ok=True)) or True
h.json(dict(erro='Rota do histórico desconhecida'), 404)
except (ValueError, TypeError) as e:
h.json(dict(erro=str(e)), 400)
return True
+88 -8
View File
@@ -32,7 +32,7 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote from urllib.parse import unquote
from pathlib import Path from pathlib import Path
from comum import autorizado, carregar_token, gravar_atomico, ler_json 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 eventos import Eventos
from servos import ControleServos from servos import ControleServos
from braco import Braco from braco import Braco
@@ -40,6 +40,9 @@ from cerebro import Cerebro
from conversa import Conversa from conversa import Conversa
from treinamento import Treinamento from treinamento import Treinamento
from api_config import ConfigAPI from api_config import ConfigAPI
from historico import Historico
from vida import Vida
from criacao import Criacao
TRAVA = threading.Lock() TRAVA = threading.Lock()
@@ -52,7 +55,11 @@ FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"]
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz", GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre", "surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
"dormir", "acordar", "aleatorio", "parar", "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 que o rosto entende mas não viram botão.
ORDENS_INTERNAS = {"calma"} ORDENS_INTERNAS = {"calma"}
@@ -62,14 +69,14 @@ APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255], "formato": "padrao"}
ARQUIVO_APARENCIA = "aparencia.json" ARQUIVO_APARENCIA = "aparencia.json"
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel. # Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js", ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js", "/voz-robo.js",
"/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task", "/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task",
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm", "/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm",
"/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"} "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"}
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar. # 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"} PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/criacao/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -185,6 +192,11 @@ def ordem_de_comando(tipo, valor):
if nome not in GESTOS and nome not in ORDENS_INTERNAS: if nome not in GESTOS and nome not in ORDENS_INTERNAS:
raise ValueError("Gesto desconhecido: " + nome) raise ValueError("Gesto desconhecido: " + nome)
return 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": if tipo == "rosto":
nome = str(valor or "").strip().lower() nome = str(valor or "").strip().lower()
if nome not in ROSTOS: if nome not in ROSTOS:
@@ -236,7 +248,7 @@ class Handler(SimpleHTTPRequestHandler):
def modulos(self): def modulos(self):
s = self.server s = self.server
return [getattr(s, nome) for nome in ("api_config", "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)] "braco", "servos", "controle") if hasattr(s, nome)]
def estado(self): def estado(self):
@@ -245,7 +257,8 @@ class Handler(SimpleHTTPRequestHandler):
with TRAVA: with TRAVA:
aparencia = dict(APARENCIA) aparencia = dict(APARENCIA)
dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)), dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)),
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS, formatos=FORMATOS) estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS, formatos=FORMATOS,
atividades=ATIVIDADES)
if hasattr(s, "controle"): if hasattr(s, "controle"):
dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy()) dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy())
if hasattr(s, "servos"): if hasattr(s, "servos"):
@@ -259,6 +272,10 @@ class Handler(SimpleHTTPRequestHandler):
dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro) dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro)
if hasattr(s, "api_config"): if hasattr(s, "api_config"):
dados["api"] = s.api_config.status() 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 return dados
def do_GET(self): def do_GET(self):
@@ -451,7 +468,7 @@ class Servidor(ThreadingHTTPServer):
def ligar_eventos(servidor): def ligar_eventos(servidor):
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento"): for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao"):
if hasattr(servidor, nome): if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar getattr(servidor, nome).publicar = EVENTOS.publicar
@@ -476,7 +493,70 @@ def main():
servidor.cerebro.chave = servidor.token servidor.cerebro.chave = servidor.token
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro) servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db') 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):
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 and not vida.sono_profundo and vida.pir_acorda():
enfileirar("acordar")
def camera(ligada):
if servidor.controle.config.get("sensorCamera") != ligada:
servidor.controle.publicar("app_config", servidor.controle.atualizar({"sensorCamera": ligada}))
def alguem_chegou():
camera(True) # modo presença: o PIR liga a câmera do celular
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
enfileirar("dormir")
servidor.historico.ao_detectar = presenca
servidor.historico.ao_presenca = alguem_chegou
servidor.historico.ao_ausencia = ninguem_por_perto
servidor.conversa.treinamento = servidor.treinamento servidor.conversa.treinamento = servidor.treinamento
ligar_eventos(servidor) ligar_eventos(servidor)
servidor.registrar = args.log servidor.registrar = args.log
+9 -1
View File
@@ -1,4 +1,12 @@
(function(){ (function(){
// Cores das receitas: o ciano padrão (#5cd6ff) vira a cor escolhida no painel e o azul-claro
// (#aaccff) um tom mais claro dela; rosa, amarelo e verde continuam (fazem parte da emoção).
function corDaReacao(hex){
const h=String(hex||'').toLowerCase(), base=(typeof CIANO!=='undefined'?CIANO:[92,214,255]);
if(h==='#5cd6ff')return base.slice();
if(h==='#aaccff')return base.map(v=>v+(255-v)*.45);
return [1,3,5].map(i=>parseInt(h.slice(i,i+2),16));
}
const icons={nenhum:'',estrela:'✦',coracao:'♥',musica:'♫',cafe:'☕',ideia:'💡'}; const icons={nenhum:'',estrela:'✦',coracao:'♥',musica:'♫',cafe:'☕',ideia:'💡'};
const badge=document.createElement('div');badge.id='reacaoSimbolo';badge.style.cssText='position:fixed;left:5%;top:6%;z-index:15;color:#5cd6ff;font:clamp(30px,7vw,64px) sans-serif;pointer-events:none;white-space:pre-wrap';document.body.append(badge); const badge=document.createElement('div');badge.id='reacaoSimbolo';badge.style.cssText='position:fixed;left:5%;top:6%;z-index:15;color:#5cd6ff;font:clamp(30px,7vw,64px) sans-serif;pointer-events:none;white-space:pre-wrap';document.body.append(badge);
let timer,last=null,fetching=false,animation;let particles=[]; let timer,last=null,fetching=false,animation;let particles=[];
@@ -42,7 +50,7 @@
for(const s of recipe.passos)if(!['aberto','feliz','surpreso','coracao','triste'].includes(s.olhos)||!['neutra','sorriso','aberta','triste'].includes(s.boca)||!/^#[0-9a-f]{6}$/i.test(s.cor)||!Number.isFinite(s.duracao)||s.duracao<.15||s.duracao>2||!Number.isFinite(s.dx)||Math.abs(s.dx)>25||!Number.isFinite(s.dy)||Math.abs(s.dy)>12||!Number.isFinite(s.escala)||s.escala<.8||s.escala>1.3)return; for(const s of recipe.passos)if(!['aberto','feliz','surpreso','coracao','triste'].includes(s.olhos)||!['neutra','sorriso','aberta','triste'].includes(s.boca)||!/^#[0-9a-f]{6}$/i.test(s.cor)||!Number.isFinite(s.duracao)||s.duracao<.15||s.duracao>2||!Number.isFinite(s.dx)||Math.abs(s.dx)>25||!Number.isFinite(s.dy)||Math.abs(s.dy)>12||!Number.isFinite(s.escala)||s.escala<.8||s.escala>1.3)return;
clearTimeout(timer);clearVisual();window.eilik.soltar();trata('acordar');MIC.ultimoSom=performance.now()/1000; clearTimeout(timer);clearVisual();window.eilik.soltar();trata('acordar');MIC.ultimoSom=performance.now()/1000;
visual(recipe); visual(recipe);
r.fila=recipe.passos.map(s=>[{forma:s.olhos,forma2:null,boca:s.boca,cor:[1,3,5].map(i=>parseInt(s.cor.slice(i,i+2),16)),dx:s.dx,dy:s.dy,esc:s.escala,ab:1,ab2:1},s.duracao]); r.fila=recipe.passos.map(s=>[{forma:s.olhos,forma2:null,boca:s.boca,cor:corDaReacao(s.cor),dx:s.dx,dy:s.dy,esc:s.escala,ab:1,ab2:1},s.duracao]);
r.fila.push([{forma:'aberto',forma2:null,boca:'neutra',cor:CIANO.slice(),dx:0,dy:0,esc:1,ab:1,ab2:1},.4]);proximo(); r.fila.push([{forma:'aberto',forma2:null,boca:'neutra',cor:CIANO.slice(),dx:0,dy:0,esc:1,ab:1,ab2:1},.4]);proximo();
timer=setTimeout(clearVisual,Math.max(recipe.figurinha?6000:0,['musica','estrela','coracao'].includes(recipe.simbolo)?4500:['ideia','cafe'].includes(recipe.simbolo)?2500:0,recipe.passos.reduce((n,s)=>n+s.duracao,0)*1000+400)); timer=setTimeout(clearVisual,Math.max(recipe.figurinha?6000:0,['musica','estrela','coracao'].includes(recipe.simbolo)?4500:['ideia','cafe'].includes(recipe.simbolo)?2500:0,recipe.passos.reduce((n,s)=>n+s.duracao,0)*1000+400));
} }
+921 -3
View File
@@ -107,7 +107,20 @@
} }
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes). // 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) { 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 respira = 1 + 0.012 * Math.sin(o.t * 1.4);
const temConteudo = !!(o.icone || o.desenho); const temConteudo = !!(o.icone || o.desenho);
if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 }; if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 };
@@ -118,6 +131,11 @@
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6); const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
const x = p.cx - w / 2, y = p.cy - h / 2; const x = p.cx - w / 2, y = p.cy - h / 2;
c.fillStyle = css(o.cor); c.fillStyle = css(o.cor);
if (o.dormindo && !temConteudo) { // dormindo: "–" (olho fechado)
const esp = Math.max(2, p.ry * 0.2);
caixa(c, p.cx - w * 0.5, p.cy + h * 0.08 - esp / 2, w, esp, esp / 2); c.fill();
return;
}
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; } if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
caixa(c, x, y, w, h, est.r * p.ry * respira); caixa(c, x, y, w, h, est.r * p.ry * respira);
c.fill(); c.fill();
@@ -132,7 +150,21 @@
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill(); c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over'; c.globalCompositeOperation = 'source-over';
if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74); if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74);
else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar else if (o.iris && fecha < 0.6 && e.bot < 0.35) {
const R = Math.min(w, h) * 0.3;
const ix = Math.max(x + R * 1.1, Math.min(x + w - R * 1.1, p.cx + (p.lx + sacada.x) * w * 0.016));
const iy = Math.max(y + R * 1.1, Math.min(y + h - R * 1.1, p.cy + h * 0.04 + (p.ly + sacada.y) * h * 0.016));
c.fillStyle = css(o.cor.map((v) => v * 0.42));
c.beginPath(); c.arc(ix, iy, R, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'destination-out';
const pupila = p.ab > 1.05 ? 0.38 : e.bot > 0.2 ? 0.62 : 0.5; // susto contrai, alegria dilata
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over';
if (est.brilho) {
c.fillStyle = 'rgba(245,250,255,0.9)';
c.beginPath(); c.arc(ix - R * 0.42, iy - R * 0.42, R * 0.2, 0, Math.PI * 2); c.fill();
}
} else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012)); const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012));
const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012)); const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012));
c.fillStyle = 'rgba(245,250,255,0.9)'; c.fillStyle = 'rgba(245,250,255,0.9)';
@@ -143,6 +175,7 @@
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal. // Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) { 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 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))); const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
c.save(); c.save();
@@ -205,7 +238,15 @@
bctx.clearRect(0, 0, colunas, linhas); bctx.clearRect(0, 0, colunas, linhas);
bctx.save(); bctx.scale(f, f); bctx.save(); bctx.scale(f, f);
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
for (const traco of o.tracos || []) {
if (typeof traco.d !== 'string') continue;
const caminho = new Path2D(traco.d);
bctx.strokeStyle = bctx.fillStyle = css(o.cor);
bctx.lineWidth = traco.largura; bctx.lineCap = 'round'; bctx.lineJoin = 'round';
if (traco.cheio) bctx.fill(caminho); else bctx.stroke(caminho);
}
desenharMaos(bctx, o, olhos, tela.height / d); desenharMaos(bctx, o, olhos, tela.height / d);
desenharZzz(bctx, o, olhos, 1.6); // em LEDs o Z precisa ser maior para ler
bctx.restore(); bctx.restore();
const px = bctx.getImageData(0, 0, colunas, linhas).data; const px = bctx.getImageData(0, 0, colunas, linhas).data;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height); ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
@@ -245,10 +286,12 @@
} else if (estilo.lente) { } else if (estilo.lente) {
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
desenharMaos(ctx, o, olhos, tela.height); desenharMaos(ctx, o, olhos, tela.height);
desenharZzz(ctx, o, olhos);
} else { } else {
cctx.clearRect(0, 0, camada.width, camada.height); cctx.clearRect(0, 0, camada.width, camada.height);
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
desenharMaos(cctx, o, olhos, tela.height); desenharMaos(cctx, o, olhos, tela.height);
desenharZzz(cctx, o, olhos);
ctx.save(); ctx.save();
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); } if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
ctx.drawImage(camada, 0, 0); ctx.drawImage(camada, 0, 0);
@@ -260,7 +303,7 @@
// ------------------------------------------------------------------ mãos // ------------------------------------------------------------------ mãos
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências // 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. // 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 maos = { modo: null, t0: null };
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); }; 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 const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
@@ -358,6 +401,16 @@
const y = fora + (y0 - fora) * v; 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; 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); 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') { } else if (maos.modo === 'pensar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1); const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta); const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
@@ -367,6 +420,28 @@
} }
} }
// ------------------------------------------------------------------ Zzz
function desenharZzz(c, o, olhos, fator) {
fator = fator || 1;
if (!o.dormindo) return;
const d = olhos.slice().sort((a, b) => a.cx - b.cx)[1]; // olho da direita na tela
const x0 = d.cx + d.ry * 0.7, y0 = d.cy - d.ry * 0.55;
c.save();
c.strokeStyle = css(o.cor); c.lineCap = 'round'; c.lineJoin = 'round';
for (let i = 0; i < 3; i++) {
const k = ((o.t || 0) * 0.32 + i / 3) % 1; // cada Z num ponto do ciclo
const tam = d.ry * (0.28 + 0.3 * k) * fator;
const x = x0 + d.ry * 0.7 * k + Math.sin(k * Math.PI * 2) * d.ry * 0.08, y = y0 - d.ry * 1.7 * k;
c.globalAlpha = Math.min(1, k / 0.15) * (1 - k);
c.lineWidth = Math.max(1.5, tam * 0.2);
c.beginPath();
c.moveTo(x - tam / 2, y - tam / 2); c.lineTo(x + tam / 2, y - tam / 2);
c.lineTo(x - tam / 2, y + tam / 2); c.lineTo(x + tam / 2, y + tam / 2);
c.stroke();
}
c.restore();
}
// --------------------------------------------------------- faixa de baixo // --------------------------------------------------------- faixa de baixo
// A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga. // A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga.
const faixa = { modo: null, texto: '', t0: null, dur: 0 }; const faixa = { modo: null, texto: '', t0: null, dur: 0 };
@@ -467,10 +542,853 @@
c.restore(); 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; } function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = { window.MotorRosto = {
desenhar, retrato, esconder, desenhar, retrato, esconder,
/** Metade da altura do olho (em frações de RY) para o estilo/formato: posiciona as sobrancelhas. */
metadeAltura: (estilo, formato) => {
const base = ESTILOS[estilo]; if (!base) return 1;
if (base.lente) return base.w * 0.5 * 1.12;
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
},
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } }, 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
+385
View File
@@ -0,0 +1,385 @@
#!/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 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):
conteudo = f'[Match]\nName={IFACE}\n\n[Network]\nAddress={IP_FIXO}/24\nGateway={GATEWAY}\nDNS={DNS}\n'
else:
conteudo = f'[Match]\nName={IFACE}\n\n[Network]\nDHCP=ipv4\n\n[DHCPv4]\nRouteMetric=100\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():
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:
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()
+19
View File
@@ -0,0 +1,19 @@
#!/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")
printf 'net.ipv4.conf.all.arp_ignore=1\nnet.ipv4.conf.all.arp_announce=2\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 import unittest
from cerebro import validar_intencao from cerebro import POR_EVENTO, Cerebro, validar_intencao
class Tests(unittest.TestCase): class Tests(unittest.TestCase):
def test_only_allowlisted_expression(self): def test_only_allowlisted_expression(self):
self.assertEqual(validar_intencao({'gesto':'feliz'}),'feliz') self.assertEqual(validar_intencao({'gesto':'feliz'}),'feliz')
for data in [{'gesto':'motor 2500'},{'gesto':'feliz','pulso':2500},[],{'gesto':'dormir'}]: for data in [{'gesto':'motor 2500'},{'gesto':'feliz','pulso':2500},[],{'gesto':'dormir'}]:
with self.assertRaises(ValueError):validar_intencao(data) 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() 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()
+160
View File
@@ -0,0 +1,160 @@
"""Histórico de detecções: sensores liga/desliga, intervalo, pedido de foto e rotas HTTP."""
import http.client
import importlib.util
import json
import sys
import tempfile
import threading
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from historico import Historico, MAX_FOTOS
spec = importlib.util.spec_from_file_location('kiosk_hist', ROOT / 'kiosk-server.py')
kiosk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kiosk)
JPEG = b'\xff\xd8' + b'foto' * 10 + b'\xff\xd9'
class HistoricoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.h = Historico(self.tmp.name, iniciar_pir=False)
self.publicados = []
self.h.publicar = lambda t, d: self.publicados.append((t, d))
def tearDown(self):
self.tmp.cleanup()
def test_intervalo_e_sensor_desligado(self):
self.assertIsNotNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=100))
self.assertIsNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=105)) # dentro dos 15 s
self.assertIsNotNone(self.h.registrar('som', 'surpreso', 0.9, agora=105)) # outro tipo vale
self.h.config['pir'] = False
self.assertIsNone(self.h.registrar('pir', agora=200))
with self.assertRaises(ValueError):
self.h.registrar('fumaça')
self.assertEqual(self.h.listar()[1], 2)
self.assertEqual(self.publicados[0][0], 'historico')
def test_pedido_e_foto(self):
ident = self.h.registrar('pir', 'presença', agora=10)
self.assertEqual(self.h.pedido_atual(), ident)
with self.assertRaises(ValueError):
self.h.guardar_foto(ident, b'nao e jpeg')
self.h.guardar_foto(ident, JPEG)
self.assertIsNone(self.h.pedido_atual())
self.assertTrue(self.h.evento(ident)['foto'])
self.assertTrue((Path(self.tmp.name) / 'fotos' / f'evento-{ident}.jpg').is_file())
def test_sem_foto_quando_desligado(self):
self.h.config['foto'] = False
self.h.registrar('pir', agora=10)
self.assertIsNone(self.h.pedido_atual())
def test_guarda_no_maximo_as_fotos_recentes(self):
self.h.config['intervalo'] = 0
ids = []
for i in range(MAX_FOTOS + 3):
ids.append(self.h.registrar('pir', agora=i))
self.h.guardar_foto(ids[-1], JPEG)
com_foto = [e for e in self.h.listar(500)[0] if e['foto']]
self.assertEqual(len(com_foto), MAX_FOTOS)
self.assertFalse(self.h.evento(ids[0])['foto'])
self.assertEqual(len(list((Path(self.tmp.name) / 'fotos').glob('*.jpg'))), MAX_FOTOS)
def test_guarda_so_os_ultimos_dias(self):
agora = 10 * 86400
velho = self.h.registrar('pir', agora=agora - 4 * 86400)
self.h.guardar_foto(velho, JPEG)
novo = self.h.registrar('pir', agora=agora - 86400)
self.assertEqual(self.h.limpar_antigos(agora), 1) # 3 dias por padrão
self.assertIsNone(self.h.evento(velho))
self.assertIsNotNone(self.h.evento(novo))
self.assertFalse((Path(self.tmp.name) / 'fotos' / f'evento-{velho}.jpg').exists())
with self.assertRaises(ValueError):
self.h.validar({'dias': 0})
def test_modo_presenca(self):
chamadas = []
self.h.ao_presenca = lambda: chamadas.append('chegou')
self.h.ao_ausencia = lambda: chamadas.append('saiu')
self.h.pir['disponivel'] = True
self.h.marcar_presenca(1000)
self.assertFalse(self.h.checar_ausencia(1000 + 4 * 60)) # 4 min: ainda presente
self.assertTrue(self.h.checar_ausencia(1000 + 5 * 60 + 1)) # 5 min: ausente
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # não repete
self.h.marcar_presenca(2000) # voltou alguém
self.assertEqual(chamadas, ['saiu', 'chegou'])
self.h.config['presenca'] = False
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)
self.h.apagar(a)
self.assertIsNone(self.h.evento(a))
self.h.limpar()
self.assertEqual(self.h.listar()[1], 0)
self.assertEqual(list((Path(self.tmp.name) / 'fotos').glob('*.jpg')), [])
class HistoricoHttpTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
self.server.token = 'token-historico'
self.server.historico = Historico(self.tmp.name, iniciar_pir=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, tipo='application/json', token='token-historico'):
c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5)
h = {'Content-Type': tipo}
if token:
h['Authorization'] = 'Bearer ' + token
if isinstance(corpo, dict):
corpo = json.dumps(corpo)
c.request(metodo, rota, body=corpo, headers=h)
r = c.getresponse(); dados = r.read(); c.close()
return r.status, dados
def test_fluxo_completo(self):
self.assertEqual(self.pedir('POST', '/historico/evento', {'tipo': 'pir'}, token=None)[0], 401)
codigo, corpo = self.pedir('POST', '/historico/evento', {'tipo': 'movimento', 'evento': 'surpreso', 'nivel': 0.3})
ident = json.loads(corpo)['id']
self.assertEqual(json.loads(self.pedir('GET', '/historico/pedido')[1])['id'], ident)
self.assertEqual(self.pedir('POST', f'/historico/foto?id={ident}', JPEG, 'image/jpeg')[0], 200)
codigo, foto = self.pedir('GET', f'/historico/foto/evento-{ident}.jpg?t=token-historico', token=None)
self.assertEqual((codigo, foto), (200, JPEG))
self.assertEqual(self.pedir('GET', '/historico/foto/../historico.db')[0], 400)
status = json.loads(self.pedir('GET', '/historico/status')[1])
self.assertEqual(status['eventos'][0]['id'], ident)
self.assertTrue(status['eventos'][0]['foto'])
self.assertEqual(self.pedir('POST', '/historico/config', {'foto': False, 'intervalo': 30})[0], 200)
self.assertEqual(self.pedir('POST', '/historico/config', {'intervalo': 9999})[0], 400)
self.assertFalse(self.server.historico.config['foto'])
if __name__ == '__main__':
unittest.main()
+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': 'gesto', 'valor': 'rm -rf'})[0], 400)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400) self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo']) 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) self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': novo})[0], 200)
kiosk.FILA.limpar() 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') 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], ['feliz', 'rosto miudo'])
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], []) 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
+188
View File
@@ -0,0 +1,188 @@
/* voz-robo.js — voz sintetizada do Carinha, no estilo dos robozinhos de cinema.
*
* Tudo é gerado na hora com Web Audio: nenhuma gravação, nenhum arquivo de terceiros.
* Cada som é uma sequência de "sílabas": um oscilador que desliza de um tom a outro, com vibrato,
* passando por um filtro de formante (a "vogal": o→i faz o "uou"). Motores das mãos viram ruído
* filtrado, e a resposta da conversa vira um balbucio que sobe no fim quando é pergunta.
*/
(function () {
'use strict';
let ruido = null; // buffer de ruído reaproveitado
const TOM = 1.25; // todas as sílabas 25% acima do tom escrito
const saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
/*
* Cadeia de "brilho metálico" (criada uma vez por AudioContext):
* voz → ressonância de lata fina (agudo, ~850 Hz) → saturação leve → compressor → saída
* Nada grave: zumbido e ressonância graves deixavam a voz com cara de sapo.
*/
function saida(c) {
if (saidas.has(c)) return saidas.get(c);
const entrada = c.createGain();
const seca = c.createGain(); seca.gain.value = 0.8;
const atraso = c.createDelay(0.05); atraso.delayTime.value = 0.00118;
const volta = c.createGain(); volta.gain.value = 0.42;
const molhada = c.createGain(); molhada.gain.value = 0.35;
entrada.connect(seca);
entrada.connect(atraso); atraso.connect(volta); volta.connect(atraso); atraso.connect(molhada);
const satura = c.createWaveShaper();
const n = 2048, curva = new Float32Array(n);
for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; curva[i] = Math.tanh(x * 1.3); }
satura.curve = curva;
seca.connect(satura); molhada.connect(satura);
const brilho = c.createBiquadFilter(); // realce leve nos agudos: "metal" limpo
brilho.type = 'peaking'; brilho.frequency.value = 3200; brilho.Q.value = 1.2; brilho.gain.value = 4;
const comp = c.createDynamicsCompressor();
comp.threshold.value = -16; comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.15;
const passaBaixa = c.createBiquadFilter();
passaBaixa.type = 'lowpass'; passaBaixa.frequency.value = 8000;
const volume = c.createGain(); volume.gain.value = 0.8;
satura.connect(brilho); brilho.connect(comp); comp.connect(passaBaixa); passaBaixa.connect(volume); volume.connect(c.destination);
saidas.set(c, entrada);
return entrada;
}
/**
* Uma sílaba. de/para: tom em Hz; vogal: [formante inicial, final] em Hz;
* vib: [Hz, profundidade em Hz]; forma: tipo de onda.
*/
function silaba(c, t0, dur, de, para, vol, op) {
op = op || {};
vol *= 2.4; // compensa a energia que o filtro de formante tira
de *= TOM; para *= TOM; // voz de robozinho: aguda
const osc = c.createOscillator();
osc.type = op.forma || 'sawtooth';
osc.frequency.setValueAtTime(de, t0);
osc.frequency.exponentialRampToValueAtTime(Math.max(20, para), t0 + dur);
if (op.vib) {
const lfo = c.createOscillator(), prof = c.createGain();
lfo.frequency.value = op.vib[0]; prof.gain.value = op.vib[1];
lfo.connect(prof); prof.connect(osc.frequency);
lfo.start(t0); lfo.stop(t0 + dur + 0.05);
}
// FM harmônico e leve: brilho de metal sem aspereza
const mod = c.createOscillator(), modProf = c.createGain();
mod.type = 'sine';
mod.frequency.setValueAtTime(de * 2, t0);
mod.frequency.exponentialRampToValueAtTime(Math.max(20, para * 2), t0 + dur);
modProf.gain.setValueAtTime(de * (op.metal ?? 0.22), t0);
modProf.gain.exponentialRampToValueAtTime(Math.max(1, para * (op.metal ?? 0.22)), t0 + dur);
mod.connect(modProf); modProf.connect(osc.frequency);
mod.start(t0); mod.stop(t0 + dur + 0.05);
// corpo: uma senoide uma oitava abaixo deixa a voz menos "apito"
const sub = c.createOscillator();
sub.type = 'sine';
sub.frequency.setValueAtTime(de / 2, t0);
sub.frequency.exponentialRampToValueAtTime(Math.max(20, para / 2), t0 + dur);
const vogal = op.vogal || [900, 1500];
const filtro = c.createBiquadFilter();
filtro.type = 'bandpass'; filtro.Q.value = op.q || 5;
filtro.frequency.setValueAtTime(vogal[0], t0);
filtro.frequency.exponentialRampToValueAtTime(vogal[1], t0 + dur);
const g = c.createGain(), gs = c.createGain();
const ataque = Math.min(0.02, dur * 0.25);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + ataque);
g.gain.setValueAtTime(vol, t0 + dur * 0.7);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
gs.gain.value = 0.12; // pouca oitava de baixo: grave demais vira coaxar
osc.connect(filtro); sub.connect(gs); gs.connect(filtro); filtro.connect(g); g.connect(saida(c));
osc.start(t0); sub.start(t0);
osc.stop(t0 + dur + 0.05); sub.stop(t0 + dur + 0.05);
}
function bufferRuido(c) {
if (ruido && ruido.sampleRate === c.sampleRate) return ruido;
ruido = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = ruido.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return ruido;
}
/** Motorzinho: ruído filtrado que sobe de tom, com um tremor mecânico. */
function servo(c, t0, dur, vol, sobe) {
const fonte = c.createBufferSource();
fonte.buffer = bufferRuido(c); fonte.loop = true;
const f = c.createBiquadFilter();
f.type = 'bandpass'; f.Q.value = 9;
f.frequency.setValueAtTime(sobe === false ? 1500 : 500, t0);
f.frequency.exponentialRampToValueAtTime(sobe === false ? 500 : 1500, t0 + dur);
const g = c.createGain(), trem = c.createOscillator(), tg = c.createGain();
trem.frequency.value = 38; tg.gain.value = vol * 0.35;
trem.connect(tg); tg.connect(g.gain);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol * 0.6, t0 + 0.05);
g.gain.setValueAtTime(vol * 0.6, t0 + dur - 0.06);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
fonte.connect(f); f.connect(g); g.connect(saida(c));
fonte.start(t0); trem.start(t0);
fonte.stop(t0 + dur + 0.05); trem.stop(t0 + dur + 0.05);
}
function clique(c, t0, vol) {
const fonte = c.createBufferSource();
fonte.buffer = bufferRuido(c);
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 3000;
const g = c.createGain();
g.gain.setValueAtTime(vol * 0.5, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.03);
fonte.connect(f); f.connect(g); g.connect(saida(c));
fonte.start(t0); fonte.stop(t0 + 0.05);
}
const O = [500, 900], I = [1800, 2400], U = [350, 700], A = [900, 1300]; // vogais (formantes)
// Cada som: (c, t, v) → agenda as sílabas. Volumes relativos ao volume do app.
const SONS = {
feliz: (c, t, v) => { silaba(c, t, 0.12, 620, 900, v, { vogal: [1200, 2000] }); silaba(c, t + 0.13, 0.18, 900, 1250, v, { vogal: I, vib: [9, 18] }); },
alegre: (c, t, v) => [560, 700, 860, 1100].forEach((f, i) => silaba(c, t + i * 0.085, 0.09, f, f * 1.25, v * 0.85, { vogal: I, forma: 'square' })),
coracao: (c, t, v) => { silaba(c, t, 0.55, 760, 560, v, { vogal: [800, 1300], vib: [7, 28] }); },
piscadinha: (c, t, v) => { clique(c, t, v); silaba(c, t + 0.04, 0.08, 1000, 1300, v * 0.6, { forma: 'square', vogal: I }); },
surpreso: (c, t, v) => { silaba(c, t, 0.22, 260, 1100, v, { vogal: [450, 2000] }); silaba(c, t + 0.22, 0.12, 1100, 820, v * 0.8, { vogal: I }); },
bravo: (c, t, v) => [0, 0.13, 0.26].forEach((d, i) => silaba(c, t + d, 0.1, 480 - i * 30, 380 - i * 30, v * 0.85, { forma: 'square', vogal: [1300, 900] })), // bipes zangados
triste: (c, t, v) => { silaba(c, t, 0.65, 640, 380, v * 0.85, { vogal: [1200, 750], vib: [5, 14] }); },
dormir: (c, t, v) => { silaba(c, t, 0.35, 460, 760, v * 0.7, { vogal: [900, 1500] }); silaba(c, t + 0.36, 0.75, 760, 340, v * 0.6, { vogal: [1500, 700], vib: [4, 8] }); },
acordar: (c, t, v) => { servo(c, t, 0.25, v * 0.5); [300, 450, 700].forEach((f, i) => silaba(c, t + 0.22 + i * 0.1, 0.1, f, f * 1.2, v * 0.8, { vogal: I, forma: 'square' })); silaba(c, t + 0.56, 0.2, 700, 1050, v, { vogal: [1200, 2200], vib: [8, 15] }); },
acenar: (c, t, v) => { servo(c, t, 0.3, v * 0.45); silaba(c, t + 0.28, 0.13, 520, 820, v, { vogal: [1000, 1900] }); silaba(c, t + 0.44, 0.2, 760, 1080, v, { vogal: I, vib: [9, 20] }); },
esconder: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 0.1, 0.08, 950, 1350, v * 0.8, { vogal: I }); for (let i = 0; i < 5; i++) silaba(c, t + 0.6 + i * 0.09, 0.05, 1200 + (i % 2) * 180, 1300, v * 0.35, { forma: 'square', vogal: I }); servo(c, t + 2.05, 0.4, v * 0.45, false); },
espiar: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 1.0, 0.3, 480, 760, v * 0.9, { vogal: [600, 1700] }); silaba(c, t + 2.0, 0.16, 800, 1150, v, { vogal: I }); },
pensar: (c, t, v) => { servo(c, t, 0.35, v * 0.4); silaba(c, t + 0.35, 0.5, 520, 640, v * 0.7, { vogal: [900, 1600], vib: [5, 10] }); [0.95, 1.12].forEach((d) => silaba(c, t + d, 0.05, 1500, 1600, v * 0.35, { forma: 'square', vogal: I })); }, // "hmm?" e bipinhos
musica: (c, t, v) => [523, 659, 784, 659, 880, 784, 659, 1047].forEach((f, i) => silaba(c, t + i * 0.26, 0.2, f, f * 1.02, v * 0.6, { forma: 'triangle', vogal: A, vib: [6, 5] })),
};
// Gerador previsível: a mesma frase sempre "soa" igual.
function semente(texto) {
let h = 2166136261;
for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
return () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; };
}
/** Balbucio de robô para uma resposta: ~1 sílaba a cada 6 letras, no máximo 16. */
function balbuciar(c, texto, v) {
texto = String(texto || '').trim();
if (!texto) return 0;
const rnd = semente(texto), n = Math.max(2, Math.min(16, Math.ceil(texto.length / 6)));
const pergunta = /\?\s*$/.test(texto), exclama = /!\s*$/.test(texto);
const vogais = [O, I, A, U];
let t = c.currentTime + 0.03, tom = 520 + rnd() * 180;
for (let i = 0; i < n; i++) {
const ultima = i === n - 1;
const dur = 0.07 + rnd() * 0.1;
let para = tom * (0.8 + rnd() * 0.5);
if (ultima) para = pergunta ? tom * 1.6 : exclama ? tom * 1.35 : tom * 0.7;
silaba(c, t, ultima ? dur + 0.08 : dur, tom, para, v * (0.65 + rnd() * 0.3), { vogal: vogais[Math.floor(rnd() * 4)], vib: rnd() < 0.3 ? [8, 14] : null, forma: rnd() < 0.25 ? 'square' : 'sawtooth' });
t += dur + 0.025 + rnd() * 0.05;
tom = Math.max(330, Math.min(1000, para * (0.9 + rnd() * 0.25)));
}
return t - c.currentTime;
}
function tocar(c, nome, v) {
const f = SONS[nome];
if (!f || !c) return false;
f(c, c.currentTime + 0.02, v);
return true;
}
window.VozRobo = { tocar, balbuciar, servo: (c, dur, v) => servo(c, c.currentTime + 0.01, dur, v), sons: Object.keys(SONS) };
})();
+1
View File
@@ -11,6 +11,7 @@
<div id="app"></div> <div id="app"></div>
<!-- mesmo motor de rosto do celular, usado nas miniaturas das reações --> <!-- mesmo motor de rosto do celular, usado nas miniaturas das reações -->
<script src="/rosto-motor.js"></script> <script src="/rosto-motor.js"></script>
<script src="/voz-robo.js"></script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+21 -6
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte'; import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
import { onMount, type Component } from 'svelte'; import { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte'; import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte'; import EStop from './lib/componentes/EStop.svelte';
@@ -7,7 +7,7 @@
import Paleta from './lib/componentes/Paleta.svelte'; import Paleta from './lib/componentes/Paleta.svelte';
import Selo from './lib/componentes/Selo.svelte'; import Selo from './lib/componentes/Selo.svelte';
import Token from './lib/componentes/Token.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 { conectar, estado } from './lib/estado.svelte';
import { paleta } from './lib/paleta.svelte'; import { paleta } from './lib/paleta.svelte';
import { nav, ROTAS, SECUNDARIAS, tituloDa, type Rota } from './lib/rotas.svelte'; import { nav, ROTAS, SECUNDARIAS, tituloDa, type Rota } from './lib/rotas.svelte';
@@ -17,17 +17,26 @@
import Movimento from './telas/Movimento.svelte'; import Movimento from './telas/Movimento.svelte';
import Reacoes from './telas/Reacoes.svelte'; import Reacoes from './telas/Reacoes.svelte';
import Rosto from './telas/Rosto.svelte'; 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> = { const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo, 'ao-vivo': AoVivo,
eventos: Eventos,
conversa: Conversa, conversa: Conversa,
reacoes: Reacoes, reacoes: Reacoes,
movimento: Movimento, movimento: Movimento,
ajustes: Ajustes, ajustes: Ajustes,
rosto: Rosto, rosto: Rosto,
guia: Guia,
criar: Criar,
}; };
const ICONES: Record<string, Component<any>> = { const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video, 'ao-vivo': Video,
eventos: Bell,
conversa: MessageCircle, conversa: MessageCircle,
reacoes: Smile, reacoes: Smile,
movimento: Hand, movimento: Hand,
@@ -86,7 +95,7 @@
} }
if (/^[1-9]$/.test(e.key)) { if (/^[1-9]$/.test(e.key)) {
const g = favoritos.lista[Number(e.key) - 1]; const g = favoritos.lista[Number(e.key) - 1];
if (g) gesto(g); if (g) tocarFavorito(g);
} }
} }
@@ -138,7 +147,10 @@
</nav> </nav>
<header class="topo"> <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"> <div class="status linha">
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}> <Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto} {conexaoTexto}
@@ -169,7 +181,7 @@
<Tela /> <Tela />
</main> </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 /> <MiniRosto />
{/if} {/if}
</div> </div>
@@ -206,7 +218,7 @@
margin: 0; margin: 0;
padding: var(--e-1); padding: var(--e-1);
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(6, 1fr);
} }
li a { li a {
display: grid; display: grid;
@@ -238,6 +250,9 @@
border-bottom: 1px solid var(--borda); border-bottom: 1px solid var(--borda);
} }
.titulo { .titulo {
display: flex;
align-items: center;
gap: 2px;
font-size: var(--t-lg); font-size: var(--t-lg);
flex: none; flex: none;
} }
+27 -1
View File
@@ -38,24 +38,32 @@ export interface Campo {
opcoes?: Opcoes; 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', titulo: 'Tela e personalidade',
ajuda: 'tela',
descricao: 'Como o rosto aparece e se comporta sozinho.', descricao: 'Como o rosto aparece e se comporta sozinho.',
campos: [ campos: [
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' }, { chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' }, { chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' }, { chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
{ chave: 'iris', rotulo: 'Mostrar íris', ajuda: 'Pupila que acompanha o olhar (Viva, Bloco e Matriz)', tipo: 'toggle' },
{ chave: 'subirBoca', rotulo: 'Subir a boca', tipo: 'slider', min: -20, max: 60, passo: 2 },
{ chave: 'distSobrancelha', rotulo: 'Distância das sobrancelhas', tipo: 'slider', min: 0, max: 60, passo: 2 },
{ chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' }, { chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
{ chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 }, { chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
{ chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 }, { chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' }, { 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: '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: '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 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', titulo: 'Sensores e reações',
ajuda: 'sensores',
descricao: 'O que o celular percebe e como reage.', descricao: 'O que o celular percebe e como reage.',
campos: [ campos: [
{ chave: 'sensorMic', rotulo: 'Microfone', ajuda: 'Só o nível de som é usado, nunca o áudio', tipo: 'toggle' }, { chave: 'sensorMic', rotulo: 'Microfone', ajuda: 'Só o nível de som é usado, nunca o áudio', tipo: 'toggle' },
@@ -68,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: '%' }, { 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', titulo: 'Vídeo e rastreamento',
ajuda: 'camera',
descricao: 'Qualidade da transmissão e rastreamento feito neste PC.', descricao: 'Qualidade da transmissão e rastreamento feito neste PC.',
campos: [ campos: [
{ chave: 'cameraLado', rotulo: 'Câmera', tipo: 'escolha', opcoes: [['user', 'Frontal'], ['environment', 'Traseira']] }, { chave: 'cameraLado', rotulo: 'Câmera', tipo: 'escolha', opcoes: [['user', 'Frontal'], ['environment', 'Traseira']] },
@@ -93,6 +118,7 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
}, },
{ {
titulo: 'Sensibilidade (avançado)', titulo: 'Sensibilidade (avançado)',
ajuda: 'sensibilidade',
descricao: 'Limiares dos sustos e intervalos entre reações.', descricao: 'Limiares dos sustos e intervalos entre reações.',
campos: [ campos: [
{ chave: 'somlim', rotulo: 'Nível para perceber som', tipo: 'slider', min: 0, max: 1, passo: 0.01 }, { 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: '🫣' }, espiar: { nome: 'Espiar', emoji: '🫣' },
pensar: { nome: 'Pensar', emoji: '🤔' }, pensar: { nome: 'Pensar', emoji: '🤔' },
musica: { nome: 'Dançar', 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); export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
const CHAVE_FAVORITOS = 'carinha_favoritos'; const CHAVE_FAVORITOS = 'carinha_favoritos';
const CHAVE_MAOS = 'carinha_favoritos_maos'; // marca que os gestos com mãos já entraram 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. */ const CHAVE_V3 = 'carinha_favoritos_v3'; // marca que o bravo e os gestos novos já entraram
export const MAX_FAVORITOS = 12; /** 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 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[] { function lerFavoritos(): string[] {
try { try {
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null'); const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) { 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); 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. // 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)); const novos = GESTOS_MAOS.filter((g) => !v.includes(g));
@@ -61,6 +103,7 @@ class Favoritos {
try { try {
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista)); localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
localStorage.setItem(CHAVE_MAOS, '1'); localStorage.setItem(CHAVE_MAOS, '1');
localStorage.setItem(CHAVE_V3, '1');
} catch { } catch {
/* ok */ /* ok */
} }
@@ -69,6 +112,9 @@ class Favoritos {
export const favoritos = new Favoritos(); export const favoritos = new Favoritos();
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome })); export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
/** 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 rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome }));
export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb })); export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb }));
export const formato = (nome: string) => tentar(() => postar('/comando', { tipo: 'formato', valor: nome })); 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"> <script lang="ts">
import type { Snippet } from 'svelte'; 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; titulo?: string;
descricao?: string; descricao?: string;
/** Tópico do guia: mostra um ⓘ ao lado do título. */
ajuda?: string;
acoes?: Snippet; acoes?: Snippet;
children: Snippet; children: Snippet;
class?: string; class?: string;
@@ -13,7 +16,7 @@
{#if titulo || acoes} {#if titulo || acoes}
<header> <header>
<div> <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} {#if descricao}<p class="fraco pequeno-texto">{descricao}</p>{/if}
</div> </div>
{#if acoes}<div class="linha">{@render acoes()}</div>{/if} {#if acoes}<div class="linha">{@render acoes()}</div>{/if}
+23 -5
View File
@@ -1,17 +1,28 @@
<script lang="ts"> <script lang="ts">
import { Star } from '@lucide/svelte'; 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(); 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> </script>
<div class="gestos" class:completo> <div class="gestos" class:completo>
{#each lista as g, i (g)} {#each lista as g, i (g)}
{@const info = GESTOS_INFO[g]} {@const info = infoDe(g)!}
<div class="item"> <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="emoji" aria-hidden="true">{info.emoji}</span>
<span class="nome">{info.nome}</span> <span class="nome">{info.nome}</span>
{#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if} {#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if}
@@ -71,6 +82,13 @@
color: var(--texto-2); color: var(--texto-2);
text-align: center; text-align: center;
} }
.gesto.criado::after {
content: '✨';
position: absolute;
left: 6px;
top: 4px;
font-size: 10px;
}
kbd { kbd {
position: absolute; position: absolute;
top: 4px; top: 4px;
+9 -2
View File
@@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; 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. // 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> </script>
<section class="grupo"> <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> <div class="itens">{@render children()}</div>
{#if rodape}<p class="rodape">{rodape}</p>{/if} {#if rodape}<p class="rodape">{rodape}</p>{/if}
</section> </section>
@@ -17,6 +20,10 @@
min-width: 0; min-width: 0;
} }
h2 { h2 {
display: flex;
align-items: center;
gap: 2px;
min-height: 26px;
padding: 0 var(--e-4); padding: 0 var(--e-4);
font-size: var(--t-xs); font-size: var(--t-xs);
font-weight: 600; font-weight: 600;
+8 -1
View File
@@ -25,6 +25,13 @@
if (['musica', 'estrela', 'coracao'].includes(receita?.simbolo)) return { emoji: SIMBOLO_EMOJI[receita.simbolo] }; if (['musica', 'estrela', 'coracao'].includes(receita?.simbolo)) return { emoji: SIMBOLO_EMOJI[receita.simbolo] };
return null; return null;
}); });
// Mesma regra do celular (reacoes-render.js): ciano padrão = cor escolhida; azul-claro = tom claro dela.
function corDaReacao(hex: string | undefined): number[] {
const h = (hex ?? '').toLowerCase(), base = estado.aparencia.cor;
if (!/^#[0-9a-f]{6}$/.test(h) || h === '#5cd6ff') return [...base];
if (h === '#aaccff') return base.map((v) => v + (255 - v) * 0.45);
return rgb(h);
}
const estilo = $derived(ROSTOS_INFO[estado.aparencia.rosto]?.novo ? estado.aparencia.rosto : 'vivo'); const estilo = $derived(ROSTOS_INFO[estado.aparencia.rosto]?.novo ? estado.aparencia.rosto : 'vivo');
$effect(() => { $effect(() => {
@@ -36,7 +43,7 @@
motor.retrato(tela, { motor.retrato(tela, {
estilo, estilo,
formato: estado.aparencia.formato ?? 'padrao', formato: estado.aparencia.formato ?? 'padrao',
cor: /^#[0-9a-f]{6}$/i.test(passo?.cor ?? '') ? rgb(passo.cor) : estado.aparencia.cor, cor: corDaReacao(passo?.cor),
forma: passo?.olhos ?? 'aberto', forma: passo?.olhos ?? 'aberto',
icone: nosOlhos.icone, icone: nosOlhos.icone,
desenho: nosOlhos.desenho ? $state.snapshot(nosOlhos.desenho) : null, desenho: nosOlhos.desenho ? $state.snapshot(nosOlhos.desenho) : null,
@@ -0,0 +1,52 @@
<script lang="ts">
import { estado } from '../estado.svelte';
// Selo ao vivo do PIR: "Presença agora" por 8 s depois de cada disparo (o HC-SR505 segura ~8 s).
const seg = $derived(estado.pir?.ultimo ? Math.max(0, Math.round((estado.agora - estado.pir.ultimo * 1000) / 1000)) : null);
const agora = $derived(seg !== null && seg < 8);
const texto = $derived(
!estado.pir?.disponivel ? 'sensor indisponível'
: agora ? 'presença agora'
: seg === null ? 'aguardando presença'
: seg < 60 ? `último há ${seg} s` : seg < 3600 ? `último há ${Math.round(seg / 60)} min` : `último há ${Math.round(seg / 3600)} h`,
);
</script>
<span class="pir" class:agora class:off={!estado.pir?.disponivel} aria-live="polite">
<span class="ponto" aria-hidden="true"></span>{texto}
</span>
<style>
.pir {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: var(--raio-pill);
font-size: var(--t-xs);
font-weight: 600;
background: var(--superficie-2);
color: var(--texto-3);
white-space: nowrap;
}
.ponto {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.agora {
background: var(--ok-suave);
color: var(--ok);
}
.agora .ponto {
animation: pulso 1s ease-in-out infinite;
}
.off {
opacity: 0.7;
}
@keyframes pulso {
50% {
box-shadow: 0 0 0 5px rgb(52 199 89 / 0.25);
}
}
</style>
+14 -1
View File
@@ -7,7 +7,8 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { estado, ouvir } from '../estado.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 quadro: HTMLIFrameElement;
let pronto = $state(false); let pronto = $state(false);
@@ -20,6 +21,13 @@
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) }); 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; let ultimaConversa = estado.conversa?.ultima?.id;
const cancelar = [ const cancelar = [
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })), ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
@@ -42,6 +50,11 @@
enviar({ tipo: 'ordem', ordem: texto }); enviar({ tipo: 'ordem', ordem: texto });
} }
/** Mostra uma legenda na faixa de baixo da prévia. */
export function legenda(texto: string) {
enviar({ tipo: 'legenda', texto });
}
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */ /** Para a tela de reações: mostra uma receita sem mandar ao celular. */
export function previa(receita: unknown) { export function previa(receita: unknown) {
enviar({ tipo: 'previa-reacao', receita }); enviar({ tipo: 'previa-reacao', receita });
+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));
+25 -3
View File
@@ -4,7 +4,7 @@
*/ */
import { ErroApi, pedir, quandoNegado, urlEventos } from './api'; import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
import { avisos } from './avisos.svelte'; 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'; export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
@@ -33,7 +33,10 @@ class Estado {
reacao = $state<Reacao | null>(null); reacao = $state<Reacao | null>(null);
treinamento = $state({ ocupado: false, erro: '' }); treinamento = $state({ ocupado: false, erro: '' });
api = $state<EstadoServidor['api'] | null>(null); api = $state<EstadoServidor['api'] | null>(null);
vida = $state<Vida | null>(null);
feed = $state<ItemFeed[]>([]); feed = $state<ItemFeed[]>([]);
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
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. */ /** Relógio de 1 s para derivar "online" sem esperar evento novo. */
agora = $state(Date.now()); agora = $state(Date.now());
@@ -106,11 +109,20 @@ function aplicarFoto(f: EstadoServidor) {
estado.reacao = f.reacao ?? null; estado.reacao = f.reacao ?? null;
if (f.treinamento) estado.treinamento = f.treinamento; if (f.treinamento) estado.treinamento = f.treinamento;
if (f.api) estado.api = f.api; if (f.api) estado.api = f.api;
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> = { const tratadores: Record<string, (d: any) => void> = {
expressao: (d: { ordem: string }) => { 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) => { telemetria: (d: Telemetria) => {
estado.app = d; estado.app = d;
@@ -139,6 +151,16 @@ const tratadores: Record<string, (d: any) => void> = {
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome); if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
}, },
treinamento: (d) => (estado.treinamento = d), treinamento: (d) => (estado.treinamento = d),
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');
},
}; };
let fonte: EventSource | null = null; let fonte: EventSource | null = null;
@@ -161,7 +183,7 @@ function abrirEventos() {
if (estado.conexao !== 'token') estado.conexao = 'caiu'; if (estado.conexao !== 'token') estado.conexao = 'caiu';
}; };
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia', for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
'reacao', 'treinamento']) { 'reacao', 'treinamento', 'historico', 'pir', 'vida', 'criacao']) {
fonte.addEventListener(tipo, (e) => { fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data); const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados); 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',
};
+85
View File
@@ -0,0 +1,85 @@
/** Histórico de detecções (/historico/*): recarrega ao abrir e quando o servidor avisa por SSE. */
import { lerToken, pedir, postar } from './api';
import { avisos } from './avisos.svelte';
import { ouvir } from './estado.svelte';
export interface EventoHistorico {
id: number;
horario: number;
tipo: 'pir' | 'movimento' | 'som';
evento: string;
nivel: number | null;
foto: boolean;
}
export interface StatusHistorico {
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number };
presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number };
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
eventos: EventoHistorico[];
total: number;
max_eventos: number;
max_fotos: number;
}
export const TIPOS_EVENTO: Record<string, { nome: string; emoji: string; cor: string }> = {
pir: { nome: 'Presença (PIR)', emoji: '🚶', cor: '#34c759' },
movimento: { nome: 'Movimento na câmera', emoji: '👀', cor: '#0a84ff' },
som: { nome: 'Som alto', emoji: '🔊', cor: '#ff9500' },
};
/** URL da foto com o token na query (a tag <img> não manda cabeçalho). */
export function urlFoto(id: number) {
const t = lerToken();
return `/historico/foto/evento-${id}.jpg` + (t ? '?t=' + encodeURIComponent(t) : '');
}
class Historico {
status = $state<StatusHistorico | null>(null);
private espera = 0;
async carregar() {
try {
this.status = await pedir<StatusHistorico>('/historico/status');
} catch (e) {
avisos.erro(e);
}
}
async configurar(parcial: Partial<StatusHistorico['config']>) {
const antes = this.status ? { ...this.status.config } : null;
if (this.status) this.status = { ...this.status, config: { ...this.status.config, ...parcial } };
try {
this.status = await postar<StatusHistorico>('/historico/config', parcial);
} catch (e) {
if (this.status && antes) this.status = { ...this.status, config: antes };
avisos.erro(e);
}
}
async apagar(id: number) {
try {
await postar('/historico/apagar', { id });
} catch (e) {
avisos.erro(e);
}
}
async limpar() {
try {
await postar('/historico/limpar');
avisos.ok('Histórico apagado.');
} catch (e) {
avisos.erro(e);
}
}
/** Vários avisos seguidos (evento + foto) viram uma recarga só. */
agendar() {
clearTimeout(this.espera);
this.espera = window.setTimeout(() => this.carregar(), 300);
}
}
export const historico = new Historico();
ouvir('historico', () => historico.status && historico.agendar());
+14 -1
View File
@@ -1,8 +1,9 @@
/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */ /** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */
import { 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'; } from './comandos.svelte';
import { estado } from './estado.svelte'; import { estado } from './estado.svelte';
import { ORDEM_GUIA, TOPICOS } from './guia';
import { nav, ROTAS } from './rotas.svelte'; import { nav, ROTAS } from './rotas.svelte';
export interface ItemPaleta { export interface ItemPaleta {
@@ -36,6 +37,18 @@ export function itensDaPaleta(): ItemPaleta[] {
registrarNaPaleta(() => [ registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })), ...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') }, { 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.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
estado.estop.ativo estado.estop.ativo
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar } ? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
+9 -3
View File
@@ -1,9 +1,10 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */ /** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto'; export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' }, { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' }, { id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' }, { id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' }, { id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
@@ -11,12 +12,17 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
]; ];
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */ /** Páginas abertas a partir de outra tela (não aparecem na navegação). */
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }]; export const 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 ?? ''; export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
function lerHash(): Rota { function lerHash(): Rota {
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota; let id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
if ((id as string) === 'historico') id = 'eventos'; // endereço antigo
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo'; return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
} }
+23
View File
@@ -81,4 +81,27 @@ export interface EstadoServidor {
reacao?: Reacao | null; reacao?: Reacao | null;
treinamento?: { ocupado: boolean; erro: string }; treinamento?: { ocupado: boolean; erro: string };
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown }; 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"> <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 { gravarToken, lerToken, postar } from '../lib/api';
import { GRUPOS, restaurarPadrao, salvarApp, type Campo } from '../lib/ajustes.svelte'; import { GRUPOS, restaurarPadrao, salvarApp, type Campo } from '../lib/ajustes.svelte';
import { avisos, tentar } from '../lib/avisos.svelte'; import { avisos, tentar } from '../lib/avisos.svelte';
import { recarregarCelular } from '../lib/comandos.svelte'; import { recarregarCelular } from '../lib/comandos.svelte';
import Ajuda from '../lib/componentes/Ajuda.svelte';
import Card from '../lib/componentes/Card.svelte'; import Card from '../lib/componentes/Card.svelte';
import Selo from '../lib/componentes/Selo.svelte'; import Selo from '../lib/componentes/Selo.svelte';
import Slider from '../lib/componentes/Slider.svelte'; import Slider from '../lib/componentes/Slider.svelte';
@@ -97,7 +98,7 @@
{#each grupos as g (g.titulo)} {#each grupos as g (g.titulo)}
<fieldset> <fieldset>
<legend> <legend>
<strong>{g.titulo}</strong> <strong class="titulo-grupo">{g.titulo} <Ajuda topico={g.ajuda} /></strong>
<span class="fraco pequeno-texto">{g.descricao}</span> <span class="fraco pequeno-texto">{g.descricao}</span>
</legend> </legend>
{#each g.campos as c (c.chave)} {#each g.campos as c (c.chave)}
@@ -137,7 +138,7 @@
</Card> </Card>
<div class="coluna"> <div class="coluna">
<Card titulo="Inteligência artificial"> <Card titulo="Inteligência artificial" ajuda="conversa">
<div class="campo"> <div class="campo">
<span>Motor da conversa</span> <span>Motor da conversa</span>
<div class="segmentado" role="radiogroup" aria-label="Motor da conversa"> <div class="segmentado" role="radiogroup" aria-label="Motor da conversa">
@@ -223,6 +224,7 @@
<RefreshCw size={14} /> Recarregar tela do celular <RefreshCw size={14} /> Recarregar tela do celular
</button> </button>
<a class="btn pequeno" href="/app.apk" download><Download size={14} /> Baixar app Android</a> <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> </div>
</Card> </Card>
</div> </div>
@@ -267,6 +269,11 @@
gap: var(--e-2); gap: var(--e-2);
align-content: start; align-content: start;
} }
.titulo-grupo {
display: inline-flex;
align-items: center;
gap: 2px;
}
legend { legend {
display: grid; display: grid;
gap: 2px; gap: 2px;
+91 -8
View File
@@ -1,18 +1,20 @@
<script lang="ts"> <script lang="ts">
import { import {
Brain, Camera, Download, Eye, Hand, 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'; } from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api'; import { postar } from '../lib/api';
import { salvarApp } from '../lib/ajustes.svelte'; import { salvarApp } from '../lib/ajustes.svelte';
import { tentar } from '../lib/avisos.svelte'; import { tentar } from '../lib/avisos.svelte';
import { 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 Celular from '../lib/componentes/Celular.svelte';
import Gestos from '../lib/componentes/Gestos.svelte'; import Gestos from '../lib/componentes/Gestos.svelte';
import Grupo from '../lib/componentes/Grupo.svelte'; import Grupo from '../lib/componentes/Grupo.svelte';
import Interruptor from '../lib/componentes/Interruptor.svelte'; import Interruptor from '../lib/componentes/Interruptor.svelte';
import Linha from '../lib/componentes/Linha.svelte'; import Linha from '../lib/componentes/Linha.svelte';
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte'; import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import VidaCartao from '../lib/componentes/VidaCartao.svelte';
import { estado } from '../lib/estado.svelte'; import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte'; import { Rastreamento } from '../lib/rastreamento.svelte';
import { VideoAoVivo } from '../lib/video.svelte'; import { VideoAoVivo } from '../lib/video.svelte';
@@ -24,6 +26,8 @@
// Palco: câmera grande e rosto no quadrinho do canto; tocar no quadrinho troca os dois. // Palco: câmera grande e rosto no quadrinho do canto; tocar no quadrinho troca os dois.
let rostoGrande = $state(false); 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(() => { onMount(() => {
video = new VideoAoVivo(elVideo); video = new VideoAoVivo(elVideo);
@@ -47,6 +51,11 @@
}); });
const cfg = $derived(estado.appConfig); 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 transmitindo = $derived(!!cfg?.preview);
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
@@ -69,6 +78,12 @@
<div class="ao-vivo"> <div class="ao-vivo">
<!-- Palco fixo: a câmera continua visível enquanto a lista rola --> <!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
<section class="palco" aria-label="Câmera e rosto"> <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}> <div class="quadro camera" class:mini={rostoGrande}>
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video> <video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
@@ -110,12 +125,14 @@
</section> </section>
<div class="lista"> <div class="lista">
<Grupo titulo="Celular"> <VidaCartao />
<Grupo titulo="Celular" ajuda="celular">
<Celular /> <Celular />
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} /> <Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
</Grupo> </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 : ''}> <Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}>
{#snippet acessorio()} {#snippet acessorio()}
<Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} /> <Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} />
@@ -146,14 +163,32 @@
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" /> <Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
</Grupo> </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"> <Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos /> <Gestos />
</Linha> </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" /> <Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
</Grupo> </Grupo>
<Grupo titulo="Aparência"> <Grupo titulo="Aparência" ajuda="aparencia">
<Linha <Linha
icone={Palette} icone={Palette}
cor="#ff9500" cor="#ff9500"
@@ -167,7 +202,7 @@
</Linha> </Linha>
</Grupo> </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 <Linha
icone={Brain} icone={Brain}
cor="#5e5ce6" cor="#5e5ce6"
@@ -180,7 +215,11 @@
</Linha> </Linha>
</Grupo> </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>
<Linha icone={History} cor="#30b0c7" rotulo="Eventos" detalhe="Presença, movimento e som, com foto" href="#/eventos" />
{#if estado.feed.length} {#if estado.feed.length}
<ol class="feed"> <ol class="feed">
{#each estado.feed.slice(0, 12) as item (item.id)} {#each estado.feed.slice(0, 12) as item (item.id)}
@@ -200,6 +239,15 @@
gap: var(--e-4); gap: var(--e-4);
align-items: start; 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: sempre visível */
.palco { .palco {
position: sticky; position: sticky;
@@ -295,6 +343,41 @@
.espaco { .espaco {
flex: 1; 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 */
.lista { .lista {
display: grid; display: grid;
+1 -1
View File
@@ -204,7 +204,7 @@
</div> </div>
</Card> </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 <Abas
rotulo="Memória" rotulo="Memória"
bind:ativa={aba} 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>
+309
View File
@@ -0,0 +1,309 @@
<script lang="ts">
import { Camera, Ear, Footprints, Image as ImageIcon, Moon, Timer, Trash2, Video } from '@lucide/svelte';
import { onMount } from 'svelte';
import Grupo from '../lib/componentes/Grupo.svelte';
import Interruptor from '../lib/componentes/Interruptor.svelte';
import Linha from '../lib/componentes/Linha.svelte';
import Modal from '../lib/componentes/Modal.svelte';
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import Slider from '../lib/componentes/Slider.svelte';
import { estado } from '../lib/estado.svelte';
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
onMount(() => {
historico.carregar();
});
const st = $derived(historico.status);
// Presença e movimento em destaque; som é opcional no filtro.
type Filtro = 'principais' | 'pir' | 'movimento' | 'som' | 'todos';
let filtro = $state<Filtro>('principais');
const FILTROS: [Filtro, string][] = [
['principais', 'Presença e movimento'], ['pir', '🚶 Presença'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['todos', 'Todos'],
];
const eventos = $derived(
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'principais' ? e.tipo !== 'som' : e.tipo === filtro)),
);
// Agrupa por dia: Hoje, Ontem, depois a data.
const dias = $derived.by(() => {
const grupos: { titulo: string; itens: EventoHistorico[] }[] = [];
const hoje = new Date(estado.agora); hoje.setHours(0, 0, 0, 0);
for (const ev of eventos) {
const d = new Date(ev.horario * 1000); d.setHours(0, 0, 0, 0);
const diff = Math.round((hoje.getTime() - d.getTime()) / 86400000);
const titulo = diff === 0 ? 'Hoje' : diff === 1 ? 'Ontem' : d.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' });
if (!grupos.length || grupos[grupos.length - 1].titulo !== titulo) grupos.push({ titulo, itens: [] });
grupos[grupos.length - 1].itens.push(ev);
}
return grupos;
});
const TITULO: Record<string, string> = { pir: 'Presença detectada', movimento: 'Movimento na câmera', som: 'Som alto' };
const DETALHE: Record<string, string> = { surpreso: 'deu um susto', acordou: 'acordou o Carinha', presença: 'sensor PIR' };
const horaCurta = (t: number) => new Date(t * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
const desde = (t: number) => {
const min = Math.max(0, Math.round((estado.agora - t * 1000) / 60000));
return min < 1 ? 'agora' : min < 60 ? `há ${min} min` : `há ${Math.round(min / 60)} h`;
};
const presenca = $derived(st?.presenca);
let aberta = $state<EventoHistorico | null>(null);
let modal = $state(false);
let confirmar = $state(false);
function verFoto(ev: EventoHistorico) {
aberta = ev;
modal = true;
}
</script>
<div class="eventos">
<header>
<div class="linha entre">
<h1>Eventos</h1>
{#if st?.config.pir}<PresencaPir />{/if}
</div>
<p class="fraco">
O que o sensor de presença e a câmera do celular perceberam, com uma foto de cada momento. Guardamos {st?.config.dias ?? 3} dias.
</p>
</header>
<div class="colunas">
<section class="lista" aria-label="Lista de eventos">
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
{#each FILTROS as [id, nome] (id)}
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id)}>{nome}</button>
{/each}
</div>
{#if !st}
<p class="fraco">Carregando eventos…</p>
{:else if !eventos.length}
<div class="vazio">
<p>Nenhum evento {filtro === 'todos' ? 'nos últimos dias' : 'com esse filtro'}.</p>
<p class="fraco pequeno-texto">Quando alguém passar na frente do sensor ou a câmera perceber movimento, aparece aqui na hora.</p>
</div>
{:else}
{#each dias as dia (dia.titulo)}
<Grupo titulo={`${dia.titulo} · ${dia.itens.length}`}>
{#each dia.itens as ev (ev.id)}
{@const info = TIPOS_EVENTO[ev.tipo]}
<div class="evento">
<span class="icone" style="--c: {info.cor}" aria-hidden="true">{info.emoji}</span>
<div class="texto">
<strong>{TITULO[ev.tipo] ?? ev.tipo}</strong>
<span class="fraco pequeno-texto">
{horaCurta(ev.horario)} · {DETALHE[ev.evento] ?? ev.evento}{ev.nivel !== null && ev.tipo !== 'pir' ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}
</span>
</div>
{#if ev.foto}
<button class="ver-foto" onclick={() => verFoto(ev)} aria-label="Ver foto de {horaCurta(ev.horario)}">
<img src={urlFoto(ev.id)} alt="" loading="lazy" />
<span>Ver foto</span>
</button>
{:else}
<span class="sem-foto fraco pequeno-texto">sem foto</span>
{/if}
<button class="btn pequeno icone fantasma" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
</div>
{/each}
</Grupo>
{/each}
{/if}
</section>
<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
icone={Footprints}
cor="#34c759"
rotulo="Câmera ligada pela presença"
detalhe={presenca ? (presenca.estado === 'presente' ? `alguém por perto · último movimento ${desde(presenca.ultimo)}` : `ninguém por perto ${desde(presenca.desde)}`) : ''}
>
{#snippet acessorio()}
<Interruptor rotulo="Câmera ligada pela presença" checked={!!st?.config.presenca} disabled={!st} onchange={(v) => historico.configurar({ presenca: v })} />
{/snippet}
</Linha>
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por">
<Slider rotulo="Desligar câmera e dormir depois de" valor={st?.config.ausencia ?? 5} min={1} max={60} passo={1} unidade=" min" onchange={(v) => historico.configurar({ ausencia: v })} />
</Linha>
</Grupo>
<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 })} />
{/snippet}
</Linha>
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera">
{#snippet acessorio()}
<Interruptor rotulo="Movimento na câmera" checked={!!st?.config.movimento} disabled={!st} onchange={(v) => historico.configurar({ movimento: v })} />
{/snippet}
</Linha>
<Linha icone={Ear} cor="#ff9500" rotulo="Som alto">
{#snippet acessorio()}
<Interruptor rotulo="Som alto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
{/snippet}
</Linha>
<Linha icone={Camera} cor="#af52de" rotulo="Tirar uma foto por evento" detalhe="Câmera frontal do S10">
{#snippet acessorio()}
<Interruptor rotulo="Tirar uma foto por evento" checked={!!st?.config.foto} disabled={!st} onchange={(v) => historico.configurar({ foto: v })} />
{/snippet}
</Linha>
<Linha icone={Timer} cor="#8e8e93" rotulo="Intervalo entre eventos do mesmo tipo">
<Slider rotulo="Intervalo" valor={st?.config.intervalo ?? 15} min={0} max={120} passo={5} unidade=" s" onchange={(v) => historico.configurar({ intervalo: v })} />
</Linha>
<Linha icone={ImageIcon} cor="#30b0c7" rotulo="Guardar">
<Slider rotulo="Dias de histórico" valor={st?.config.dias ?? 3} min={1} max={30} passo={1} unidade=" dias" onchange={(v) => historico.configurar({ dias: v })} />
</Linha>
</Grupo>
<div class="limpar">
{#if confirmar}
<p class="pequeno-texto">Apagar todos os eventos e fotos?</p>
<div class="linha">
<button class="btn perigo pequeno" onclick={() => { confirmar = false; historico.limpar(); }}>Apagar tudo</button>
<button class="btn pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
</div>
{:else}
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!st?.total}><Trash2 size={14} /> Limpar eventos</button>
{/if}
</div>
</aside>
</div>
</div>
<Modal bind:aberto={modal} titulo={aberta ? `${TITULO[aberta.tipo]} · ${hora(aberta.horario)}` : 'Foto'}>
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
</Modal>
<style>
.eventos {
display: grid;
gap: var(--e-5);
}
header {
display: grid;
gap: var(--e-2);
}
header p {
max-width: 70ch;
}
.colunas {
display: grid;
gap: var(--e-5);
align-items: start;
}
.lista,
.lado {
display: grid;
gap: var(--e-5);
min-width: 0;
}
.filtros {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.filtros button {
min-height: 34px;
padding: 0 var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio-pill);
background: var(--superficie);
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.filtros button[aria-checked='true'] {
background: var(--destaque);
border-color: var(--destaque);
color: var(--destaque-texto);
}
.vazio {
display: grid;
gap: var(--e-1);
padding: var(--e-6);
text-align: center;
border: 1px dashed var(--borda-forte);
border-radius: var(--raio-lg);
}
.evento {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-2) var(--e-3) var(--e-2) var(--e-4);
min-height: 64px;
}
.evento + .evento {
border-top: 1px solid var(--borda);
}
.icone {
flex: none;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: color-mix(in srgb, var(--c) 18%, transparent);
font-size: 1.1rem;
}
.texto {
flex: 1;
display: grid;
min-width: 0;
}
.ver-foto {
flex: none;
display: flex;
align-items: center;
gap: var(--e-2);
padding: 3px var(--e-3) 3px 3px;
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--destaque);
font: 600 var(--t-xs) var(--fonte);
cursor: zoom-in;
}
.ver-foto img {
width: 56px;
height: 40px;
object-fit: cover;
border-radius: 7px;
}
.sem-foto {
flex: none;
}
.limpar {
display: grid;
gap: var(--e-2);
padding: 0 var(--e-2);
}
.grande {
width: 100%;
border-radius: var(--raio);
}
@media (max-width: 520px) {
.ver-foto span {
display: none;
}
.ver-foto {
padding: 3px;
}
}
@media (min-width: 1100px) {
.colunas {
grid-template-columns: 1fr 380px;
}
.lado {
position: sticky;
top: calc(var(--topo-altura) + var(--e-5));
}
}
</style>
+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} {/if}
{:else if aba === 'criar'} {:else if aba === 'criar'}
<div class="grade-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}> <form class="grade" onsubmit={desenhar}>
<label class="campo"> <label class="campo">
<span>O que desenhar</span> <span>O que desenhar</span>
@@ -204,7 +207,7 @@
</Card> </Card>
</div> </div>
{:else if aba === 'gestos'} {: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 /> <Gestos completo />
</Card> </Card>
{:else} {:else}
@@ -249,7 +252,7 @@
</div> </div>
<aside class="lateral"> <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> <p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p>
</aside> </aside>
</div> </div>
+79 -10
View File
@@ -4,12 +4,30 @@
cor, CORES, FORMATOS_INFO, formato, gesto, GESTOS_INFO, hex, nomeRosto, rgb, rosto, ROSTOS_INFO, cor, CORES, FORMATOS_INFO, formato, gesto, GESTOS_INFO, hex, nomeRosto, rgb, rosto, ROSTOS_INFO,
} from '../lib/comandos.svelte'; } from '../lib/comandos.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte'; import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Slider from '../lib/componentes/Slider.svelte';
import Toggle from '../lib/componentes/Toggle.svelte'; import Toggle from '../lib/componentes/Toggle.svelte';
import { salvarApp } from '../lib/ajustes.svelte';
import { estado } from '../lib/estado.svelte'; import { estado } from '../lib/estado.svelte';
let previa = $state<{ ordem: (t: string) => void }>(); let previa = $state<{ ordem: (t: string) => void; legenda: (t: string) => void }>();
// Por padrão as emoções só aparecem aqui na prévia; ligado, vão também para o celular. // Ouvir a voz de robô aqui no PC (o clique na tecla libera o áudio do navegador).
let noCelular = $state(false); let ouvirAqui = $state(true);
let audio: AudioContext | null = null;
function voz(): AudioContext | null {
const AC = window.AudioContext || (window as any).webkitAudioContext;
if (!ouvirAqui || !AC || !(window as any).VozRobo) return null;
audio ??= new AC();
if (audio.state === 'suspended') audio.resume();
return audio;
}
const FRASE = 'Oi! Eu sou o Carinha. Quer brincar comigo?';
function testarFala() {
previa?.legenda(FRASE);
const c = voz();
if (c) (window as any).VozRobo.balbuciar(c, FRASE, 0.5);
}
// As emoções vão para o celular (e a prévia mostra junto); "só na prévia" serve para testar quieto.
let soPrevia = $state(false);
const atual = $derived(estado.aparencia.rosto); const atual = $derived(estado.aparencia.rosto);
const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao'); const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao');
@@ -20,8 +38,10 @@
const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'dormir', 'acordar']; const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'dormir', 'acordar'];
function emocao(g: string) { function emocao(g: string) {
if (noCelular) gesto(g); if (soPrevia) previa?.ordem(g);
else previa?.ordem(g); else gesto(g);
const c = voz();
if (c) (window as any).VozRobo.tocar(c, g, 0.5);
} }
const IDEIAS: { titulo: string; texto: string; feito: boolean; onde: string }[] = [ const IDEIAS: { titulo: string; texto: string; feito: boolean; onde: string }[] = [
@@ -30,6 +50,7 @@
{ titulo: 'Toque na tela', texto: 'Cutucar dá susto e risada; deslizar o dedo é carinho; tocar acorda.', feito: true, onde: 'rosto' }, { titulo: 'Toque na tela', texto: 'Cutucar dá susto e risada; deslizar o dedo é carinho; tocar acorda.', feito: true, onde: 'rosto' },
{ titulo: 'Mãos', texto: 'Mãozinhas na cor dos olhos: esconde o rosto, espia, acena e pensa com a mão no queixo.', feito: true, onde: 'rosto' }, { titulo: 'Mãos', texto: 'Mãozinhas na cor dos olhos: esconde o rosto, espia, acena e pensa com a mão no queixo.', feito: true, onde: 'rosto' },
{ titulo: 'Faixa de baixo', texto: 'Legenda da conversa sendo digitada, pontinhos enquanto pensa, equalizador ao dançar e carga quando está no carregador.', feito: true, onde: 'rosto' }, { titulo: 'Faixa de baixo', texto: 'Legenda da conversa sendo digitada, pontinhos enquanto pensa, equalizador ao dançar e carga quando está no carregador.', feito: true, onde: 'rosto' },
{ titulo: 'Voz de robô', texto: 'Chilreios, “uou!”, “ohh” e um balbucio quando responde, sintetizados na hora; motorzinho quando as mãos se mexem.', feito: true, onde: 'rosto' },
{ titulo: 'Fala pelo próprio celular', texto: 'A resposta da conversa sai no alto-falante do S10, com os olhos pulsando na voz.', feito: false, onde: 'app' }, { titulo: 'Fala pelo próprio celular', texto: 'A resposta da conversa sai no alto-falante do S10, com os olhos pulsando na voz.', feito: false, onde: 'app' },
{ titulo: 'Ritmo do dia', texto: 'Boceja à noite, fica sonolento com bateria baixa, mostra um raio quando carrega.', feito: false, onde: 'rosto e servidor' }, { titulo: 'Ritmo do dia', texto: 'Boceja à noite, fica sonolento com bateria baixa, mostra um raio quando carrega.', feito: false, onde: 'rosto e servidor' },
{ titulo: 'Contato visual', texto: 'Sustenta o olhar de quem a câmera vê e desvia quando fica tímido.', feito: false, onde: 'rosto' }, { titulo: 'Contato visual', texto: 'Sustenta o olhar de quem a câmera vê e desvia quando fica tímido.', feito: false, onde: 'rosto' },
@@ -49,7 +70,7 @@
<section class="bancada" aria-label="Prévia"> <section class="bancada" aria-label="Prévia">
<div class="celular"> <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="camera-frontal" aria-hidden="true"></span>
<span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span> <span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span>
</div> </div>
@@ -59,11 +80,20 @@
<button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button> <button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button>
{/each} {/each}
</div> </div>
<div class="linha">
<button class="tecla" onclick={testarFala}>🗣️ Testar fala</button>
</div>
<Toggle <Toggle
rotulo="Mandar as emoções também para o celular" rotulo="Ouvir a voz de robô aqui no PC"
ajuda="Desligado, elas aparecem só na prévia" ajuda="Sons sintetizados na hora, inspirados nos robozinhos de cinema"
checked={noCelular} checked={ouvirAqui}
onchange={(v) => (noCelular = v)} onchange={(v) => (ouvirAqui = v)}
/>
<Toggle
rotulo="Testar só na prévia, sem mandar ao celular"
ajuda="Desligado, cada emoção aparece no celular na hora"
checked={soPrevia}
onchange={(v) => (soPrevia = v)}
/> />
</section> </section>
@@ -102,6 +132,39 @@
{/if} {/if}
</section> </section>
<section class="bloco-escolha" aria-labelledby="t-detalhes">
<h2 id="t-detalhes">Detalhes do rosto</h2>
<div class="detalhes">
<Toggle rotulo="Boca" checked={estado.appConfig?.boca !== false} disabled={!estado.appConfig} onchange={(v) => salvarApp({ boca: v })} />
<Toggle rotulo="Sobrancelhas" checked={!!estado.appConfig?.sobrancelhas} disabled={!estado.appConfig} onchange={(v) => salvarApp({ sobrancelhas: v })} />
<Toggle
rotulo="Íris"
ajuda={aceitaFormato ? 'Pupila que acompanha o olhar' : 'Vale para Viva, Bloco e Matriz'}
checked={!!estado.appConfig?.iris}
disabled={!estado.appConfig}
onchange={(v) => salvarApp({ iris: v })}
/>
</div>
<div class="detalhes">
<Slider
rotulo="Subir a boca"
valor={Number(estado.appConfig?.subirBoca ?? 0)}
min={-20}
max={60}
passo={2}
onchange={(v) => salvarApp({ subirBoca: v })}
/>
<Slider
rotulo="Distância das sobrancelhas"
valor={Number(estado.appConfig?.distSobrancelha ?? 16)}
min={0}
max={60}
passo={2}
onchange={(v) => salvarApp({ distSobrancelha: v })}
/>
</div>
</section>
<section class="bloco-escolha" aria-labelledby="t-cor"> <section class="bloco-escolha" aria-labelledby="t-cor">
<h2 id="t-cor">Cor dos olhos</h2> <h2 id="t-cor">Cor dos olhos</h2>
<div class="cores"> <div class="cores">
@@ -328,6 +391,12 @@
height: 18px; height: 18px;
border-radius: 6px; border-radius: 6px;
} }
.detalhes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
gap: var(--e-2) var(--e-6);
max-width: 780px;
}
.cores { .cores {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+1 -1
View File
@@ -125,7 +125,7 @@
</Card> </Card>
<div class="coluna"> <div class="coluna">
<Card titulo="Mover"> <Card titulo="Mover" ajuda="movimento">
<Toggle <Toggle
rotulo="Ativar movimento físico" 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'} ajuda={!st?.disponivel ? 'PCA9685 indisponível neste servidor' : estado.estop.ativo ? 'Rearme o E-Stop primeiro' : 'Com isso desligado, tudo acontece só na tela'}
+1 -1
View File
@@ -6,7 +6,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100'; const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos', const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face', '/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face',
'/carinha.html', '/reacoes-render.js', '/rosto-motor.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk']; '/carinha.html', '/reacoes-render.js', '/rosto-motor.js', '/voz-robo.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],