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>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
<script lang="ts">
|
||||
import { Gauge, Moon, Sparkles } from '@lucide/svelte';
|
||||
import { estado } from '../estado.svelte';
|
||||
import { configurarVida, cuidar, CUIDADOS, NECESSIDADES } from '../vida.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);
|
||||
|
||||
const RITMOS = [
|
||||
{ valor: 'calmo', nome: 'Calmo' },
|
||||
{ valor: 'normal', nome: 'Normal' },
|
||||
{ valor: 'agitado', nome: 'Agitado' },
|
||||
] 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 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"
|
||||
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'}</strong>
|
||||
<span>{idade(v.idade_dias)}{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">
|
||||
{#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">
|
||||
{#snippet acessorio()}
|
||||
<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="Das 23h às 7h, se não tiver ninguém por perto">
|
||||
{#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 {
|
||||
font-size: var(--t-md);
|
||||
}
|
||||
.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>
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
|
||||
import { avisos } from './avisos.svelte';
|
||||
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
|
||||
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria, Vida } from './tipos';
|
||||
|
||||
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
|
||||
|
||||
@@ -33,6 +33,7 @@ class Estado {
|
||||
reacao = $state<Reacao | null>(null);
|
||||
treinamento = $state({ ocupado: false, erro: '' });
|
||||
api = $state<EstadoServidor['api'] | null>(null);
|
||||
vida = $state<Vida | null>(null);
|
||||
feed = $state<ItemFeed[]>([]);
|
||||
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
|
||||
pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
|
||||
@@ -108,12 +109,14 @@ function aplicarFoto(f: EstadoServidor) {
|
||||
estado.reacao = f.reacao ?? null;
|
||||
if (f.treinamento) estado.treinamento = f.treinamento;
|
||||
if (f.api) estado.api = f.api;
|
||||
if (f.vida) estado.vida = f.vida;
|
||||
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca };
|
||||
}
|
||||
|
||||
const tratadores: Record<string, (d: any) => void> = {
|
||||
expressao: (d: { ordem: string }) => {
|
||||
if (d.ordem !== 'calma') noFeed('expressao', 'Expressão: ' + d.ordem);
|
||||
// 'humor x' é o modo vida avisando o celular; aparece no cartão Vida, não no feed
|
||||
if (d.ordem !== 'calma' && !d.ordem.startsWith('humor ')) noFeed('expressao', 'Expressão: ' + d.ordem);
|
||||
},
|
||||
telemetria: (d: Telemetria) => {
|
||||
estado.app = d;
|
||||
@@ -142,6 +145,11 @@ const tratadores: Record<string, (d: any) => void> = {
|
||||
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
|
||||
},
|
||||
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;
|
||||
@@ -169,7 +177,7 @@ function abrirEventos() {
|
||||
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
||||
};
|
||||
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
||||
'reacao', 'treinamento', 'historico', 'pir']) {
|
||||
'reacao', 'treinamento', 'historico', 'pir', 'vida']) {
|
||||
fonte.addEventListener(tipo, (e) => {
|
||||
const dados = JSON.parse((e as MessageEvent).data);
|
||||
tratadores[tipo]?.(dados);
|
||||
|
||||
@@ -82,4 +82,22 @@ export interface EstadoServidor {
|
||||
treinamento?: { ocupado: boolean; erro: string };
|
||||
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
|
||||
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||
vida?: Vida;
|
||||
}
|
||||
|
||||
/** Modo vida (/vida/status e SSE 'vida'): necessidades de 0 a 100, 100 = satisfeito. */
|
||||
export interface Vida {
|
||||
config: { ligado: boolean; criar_emojis: boolean; max_emojis_dia: number; ritmo: 'calmo' | 'normal' | 'agitado'; noite: boolean };
|
||||
necessidades: { energia: number; alegria: number; social: number; diversao: number };
|
||||
humor: string;
|
||||
emoji: string;
|
||||
nome_humor: string;
|
||||
dormindo: boolean;
|
||||
nascimento: number;
|
||||
idade_dias: number;
|
||||
emojis_hoje: number;
|
||||
ultimo_emoji: number | null;
|
||||
/** '' quando pode inventar figurinha agora; senão, o motivo. */
|
||||
pode_criar: string;
|
||||
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida'; texto: string }[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
import Linha from '../lib/componentes/Linha.svelte';
|
||||
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||
import VidaCartao from '../lib/componentes/VidaCartao.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
import { Rastreamento } from '../lib/rastreamento.svelte';
|
||||
import { VideoAoVivo } from '../lib/video.svelte';
|
||||
@@ -122,6 +123,8 @@ const pirAtivo = $derived(
|
||||
</section>
|
||||
|
||||
<div class="lista">
|
||||
<VidaCartao />
|
||||
|
||||
<Grupo titulo="Celular">
|
||||
<Celular />
|
||||
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
|
||||
|
||||
Reference in New Issue
Block a user